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/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/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..b3f48f2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1770548347159, "tag": "0007_fine_human_cannonball", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "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/package.json b/package.json index 40cebad..6de2ca0 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", @@ -73,12 +73,13 @@ "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", "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..050ed3b 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 @@ -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 @@ -60,8 +63,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 +216,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 +228,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 +248,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 +910,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'} @@ -2056,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'} @@ -2876,8 +2889,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 +3751,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 +3764,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 +3786,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 +4670,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': @@ -5940,6 +5959,8 @@ snapshots: cssesc@3.0.0: {} + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6649,7 +6670,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/app.d.ts b/src/app.d.ts index d3e8d1e..230eca4 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,7 @@ // See https://svelte.dev/docs/kit/types#app.d.ts import type { Session, User } from 'better-auth'; +import type { UserRole } from '$lib/roles'; // for information about these interfaces interface DevMotionAPI { @@ -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/ai/ai-operations.svelte.ts b/src/lib/ai/ai-operations.svelte.ts index f35f80d..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 { @@ -25,7 +24,6 @@ import type { RemoveKeyframeInput, RemoveKeyframeOutput } from './schemas'; -import { SvelteMap } from 'svelte/reactivity'; import { mutateCreateLayer, mutateAnimateLayer, @@ -39,32 +37,13 @@ import { type MutationContext } from './mutations'; -// ============================================ -// 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 +61,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; @@ -125,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/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 b74ea55..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 @@ -96,8 +97,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 @@ -125,56 +126,5 @@ ${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. - -## Project state -${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}`; +6. Create layers one at a time; do not batch unrelated layers in a single call.`; } diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index 45b9fb3..888be6d 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -1,7 +1,7 @@
@@ -253,24 +252,28 @@ 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" >
- + {:else} + + + {/if} {#if chat.messages.length > 0}
+ diff --git a/src/lib/functions/admin.remote.ts b/src/lib/functions/admin.remote.ts new file mode 100644 index 0000000..d0a59b9 --- /dev/null +++ b/src/lib/functions/admin.remote.ts @@ -0,0 +1,95 @@ +import { query } from '$app/server'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema/auth'; +import { project } from '$lib/server/db/schema/projects'; +import { aiUsageLog } from '$lib/server/db/schema/ai'; +import { eq, sql, count, and, gte, lte } from 'drizzle-orm'; +import { z } from 'zod'; +import { checkRole } from './auth.remote'; + +export const getAdminStats = query( + z.object({ + // Accept both Date objects and coerce-able strings (datetime-local values) + from: z.coerce.date(), + to: z.coerce.date() + }), + async ({ from, to }) => { + await checkRole('admin'); + + // User list with all-time project counts (no date filter on projects) + const userStats = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + createdAt: user.createdAt, + projectCount: sql`cast(count(distinct ${project.id}) as int)` + }) + .from(user) + .leftJoin(project, eq(project.userId, user.id)) + .groupBy(user.id, user.name, user.email, user.createdAt) + .orderBy(user.createdAt); + + // Per-user per-model breakdown: run count + cost filtered by date range + // Uses proper Drizzle gte/lte operators so dates are parameterised correctly + const modelBreakdown = await db + .select({ + userId: aiUsageLog.userId, + modelId: aiUsageLog.modelId, + runs: count(aiUsageLog.id), + costUsd: sql`coalesce(sum(${aiUsageLog.estimatedCost}), 0)` + }) + .from(aiUsageLog) + .where(and(gte(aiUsageLog.createdAt, from), lte(aiUsageLog.createdAt, to))) + .groupBy(aiUsageLog.userId, aiUsageLog.modelId) + .orderBy(aiUsageLog.userId, aiUsageLog.modelId); + + // Group model rows by userId + const modelsByUser = new Map(); + for (const row of modelBreakdown) { + if (!modelsByUser.has(row.userId)) modelsByUser.set(row.userId, []); + modelsByUser.get(row.userId)!.push(row); + } + + // All projects (public + private) – admins see everything + const allProjects = await db + .select({ + id: project.id, + name: project.name, + isPublic: project.isPublic, + userId: project.userId, + updatedAt: project.updatedAt, + views: project.views + }) + .from(project) + .orderBy(project.updatedAt); + + // Group projects by userId + const projectsByUser = new Map(); + for (const p of allProjects) { + if (!p.userId) continue; + if (!projectsByUser.has(p.userId)) projectsByUser.set(p.userId, []); + projectsByUser.get(p.userId)!.push(p); + } + + return userStats.map((u) => { + const models = (modelsByUser.get(u.id) ?? []).map((m) => ({ + modelId: m.modelId, + runs: m.runs, + // USD → cents, 4 decimal places + costCents: Math.round(m.costUsd * 100 * 10000) / 10000 + })); + + const totalCostCents = models.reduce((s, m) => s + m.costCents, 0); + + return { + ...u, + totalCostCents, + models, + projects: (projectsByUser.get(u.id) ?? []).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ) + }; + }); + } +); diff --git a/src/lib/functions/auth.remote.ts b/src/lib/functions/auth.remote.ts index 8ffea0a..473ffa7 100644 --- a/src/lib/functions/auth.remote.ts +++ b/src/lib/functions/auth.remote.ts @@ -61,3 +61,10 @@ export const getUser = query(async () => { const { locals } = getRequestEvent(); return locals.user; }); + +export const checkRole = query(z.enum(['admin', 'user']), async (role) => { + const { locals } = getRequestEvent(); + if (locals.user?.role !== role) { + redirect(303, '/'); + } +}); diff --git a/src/lib/functions/projects.remote.ts b/src/lib/functions/projects.remote.ts index 89dc6d1..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,15 +79,18 @@ export const getProject = query(z.object({ id: z.string() }), async ({ id }) => } const isOwner = locals.user?.id === result.userId; + // 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) { + if (!result.isPublic && !isOwner && !isAdmin) { return { error: 'access_denied' as const }; } 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 new file mode 100644 index 0000000..3ea315d --- /dev/null +++ b/src/lib/roles.ts @@ -0,0 +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 8189227..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, @@ -61,5 +62,18 @@ export const auth = betterAuth({ } } }) + }, + user: { + additionalFields: { + role: { + type: [...userRoles], + 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..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(), @@ -10,7 +14,8 @@ export const user = pgTable('user', { updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull() + .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 cceea64..c929fcd 100644 --- a/src/routes/(app)/chat/+server.ts +++ b/src/routes/(app)/chat/+server.ts @@ -2,7 +2,7 @@ * 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'; @@ -151,17 +151,34 @@ 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); + 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(), @@ -192,8 +209,34 @@ export const POST: RequestHandler = async ({ request, locals }) => { } }); + function enableCacheControl(messages: ModelMessage[]) { + return messages.map((message) => { + 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: { + ...message.providerOptions, + openrouter: { + ...(message.providerOptions?.openrouter as Record | undefined), + cacheControl: { type: 'ephemeral' /* ttl: '1h' */ } + } + } + }; + }); + } + const result = await agent.stream({ - messages: await convertToModelMessages(messages) + messages: [ + ...enableCacheControl([...(await convertToModelMessages(messages))]), + { + role: 'system', + content: JSON.stringify(project) + } + ] }); return result.toUIMessageStreamResponse(); diff --git a/src/routes/(marketing)/admin/+page.svelte b/src/routes/(marketing)/admin/+page.svelte new file mode 100644 index 0000000..5ea93df --- /dev/null +++ b/src/routes/(marketing)/admin/+page.svelte @@ -0,0 +1,142 @@ + + +
+

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} +
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 };