From ddde54d22ffd1b81c009505722c5caa189774633 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 30 Dec 2025 00:46:03 +0800 Subject: [PATCH] feat(stage-ui): character settings page (#847) --- .../drizzle/0002_special_fabian_cortez.sql | 2 + .../0003_flawless_dreaming_celestial.sql | 3 + apps/server/drizzle/meta/0002_snapshot.json | 1059 ++++++++++++++++ apps/server/drizzle/meta/0003_snapshot.json | 1077 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 14 + .../src/routes/__test__/characters.test.ts | 191 +++ apps/server/src/routes/characters.ts | 4 +- apps/server/src/schemas/characters.ts | 14 +- apps/server/src/schemas/chats.ts | 13 +- .../src/services/__test__/characters.test.ts | 156 +++ apps/server/src/services/characters.ts | 42 +- apps/server/src/services/db.ts | 2 +- apps/server/src/utils/id.ts | 12 + apps/stage-web/package.json | 1 + apps/stage-web/src/composables/api.ts | 29 +- .../characters/components/CharacterCreate.vue | 22 + .../characters/components/CharacterDialog.vue | 294 +++++ .../characters/components/CharacterItem.vue | 114 ++ .../src/pages/settings/characters/index.vue | 134 ++ apps/stage-web/src/stores/characters.ts | 147 +++ apps/stage-web/src/types/character.ts | 168 +++ .../stage-layouts/src/layouts/settings.vue | 4 + .../src/components/Form/Field/FieldInput.vue | 2 +- packages/ui/src/components/Misc/Button.vue | 36 +- pnpm-lock.yaml | 8 +- pnpm-workspace.yaml | 10 +- 26 files changed, 3501 insertions(+), 57 deletions(-) create mode 100644 apps/server/drizzle/0002_special_fabian_cortez.sql create mode 100644 apps/server/drizzle/0003_flawless_dreaming_celestial.sql create mode 100644 apps/server/drizzle/meta/0002_snapshot.json create mode 100644 apps/server/drizzle/meta/0003_snapshot.json create mode 100644 apps/server/src/routes/__test__/characters.test.ts create mode 100644 apps/server/src/services/__test__/characters.test.ts create mode 100644 apps/server/src/utils/id.ts create mode 100644 apps/stage-web/src/pages/settings/characters/components/CharacterCreate.vue create mode 100644 apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue create mode 100644 apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue create mode 100644 apps/stage-web/src/pages/settings/characters/index.vue create mode 100644 apps/stage-web/src/stores/characters.ts create mode 100644 apps/stage-web/src/types/character.ts diff --git a/apps/server/drizzle/0002_special_fabian_cortez.sql b/apps/server/drizzle/0002_special_fabian_cortez.sql new file mode 100644 index 000000000..e9ed36071 --- /dev/null +++ b/apps/server/drizzle/0002_special_fabian_cortez.sql @@ -0,0 +1,2 @@ +ALTER TABLE "avatar_model" ADD COLUMN "character_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "avatar_model" ADD CONSTRAINT "avatar_model_character_id_characters_id_fk" FOREIGN KEY ("character_id") REFERENCES "public"."characters"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/0003_flawless_dreaming_celestial.sql b/apps/server/drizzle/0003_flawless_dreaming_celestial.sql new file mode 100644 index 000000000..2372068f1 --- /dev/null +++ b/apps/server/drizzle/0003_flawless_dreaming_celestial.sql @@ -0,0 +1,3 @@ +ALTER TABLE "avatar_model" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "characters" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "character_i18n" ADD COLUMN "deleted_at" timestamp; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..089595a08 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1059 @@ +{ + "id": "271303e7-879f-43ba-89b4-72e2c0bca5d1", + "prevId": "8adb8523-37fc-4c77-84f4-3a3f21c0a013", + "version": "7", + "dialect": "postgresql", + "tables": { + "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": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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()" + } + }, + "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": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "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": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_members_user_id_user_id_fk": { + "name": "chat_members_user_id_user_id_fk", + "tableFrom": "chat_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "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, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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/apps/server/drizzle/meta/0003_snapshot.json b/apps/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..436c89785 --- /dev/null +++ b/apps/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1077 @@ +{ + "id": "09d6ae37-aeaa-437d-a912-e6e5d5e4f5e7", + "prevId": "271303e7-879f-43ba-89b4-72e2c0bca5d1", + "version": "7", + "dialect": "postgresql", + "tables": { + "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": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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()" + } + }, + "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": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "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, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "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, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_members_user_id_user_id_fk": { + "name": "chat_members_user_id_user_id_fk", + "tableFrom": "chat_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "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, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 2224d1ffa..680f9b4a0 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1766998160836, "tag": "0001_simple_garia", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1767024282458, + "tag": "0002_special_fabian_cortez", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1767025034768, + "tag": "0003_flawless_dreaming_celestial", + "breakpoints": true } ] } diff --git a/apps/server/src/routes/__test__/characters.test.ts b/apps/server/src/routes/__test__/characters.test.ts new file mode 100644 index 000000000..b075ba910 --- /dev/null +++ b/apps/server/src/routes/__test__/characters.test.ts @@ -0,0 +1,191 @@ +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createCharacterRoutes } from '../characters' + +describe('characterRoutes', () => { + let characterService: any + let app: Hono + + beforeEach(() => { + characterService = { + findById: vi.fn(), + findByOwnerId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } + auth = { + $Infer: { + Session: { + user: {}, + session: {}, + }, + }, + } + + const routes = createCharacterRoutes(characterService) + app = new Hono() + + app.use('*', async (c, next) => { + const user = (c.env as any)?.user + if (user) { + c.set('user', user) + } + await next() + }) + + app.route('/', routes) + }) + + it('get / should return unauthorized if no user', async () => { + const res = await app.request('/') + expect(res.status).toBe(401) + }) + + it('get / should return characters for user', async () => { + const mockUser = { id: 'user-1' } + const mockChars = [{ id: 'char-1' }] + characterService.findByOwnerId.mockResolvedValue(mockChars) + + const res = await app.fetch(new Request('http://localhost/'), { user: mockUser } as any) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(mockChars) + }) + + it('get /:id should return 404 if not found', async () => { + characterService.findById.mockResolvedValue(null) + const res = await app.request('/char-1') + expect(res.status).toBe(404) + }) + + it('get /:id should return character', async () => { + const mockChar = { id: 'char-1' } + characterService.findById.mockResolvedValue(mockChar) + const res = await app.request('/char-1') + expect(res.status).toBe(200) + expect(await res.json()).toEqual(mockChar) + }) + + it('post / should return unauthorized if no user', async () => { + const res = await app.request('/', { method: 'POST' }) + expect(res.status).toBe(401) + }) + + it('post / should validate body and create character', async () => { + const mockUser = { id: 'user-1' } + const payload = { + character: { id: 'c1', version: '1', coverUrl: 'url', characterId: 'cid' }, + } + characterService.create.mockResolvedValue({ id: 'new-id' }) + + const res = await app.fetch(new Request('http://localhost/', { + method: 'POST', + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + + expect(res.status).toBe(201) + }) + + it('post / should return 400 on invalid body', async () => { + const mockUser = { id: 'user-1' } + const res = await app.fetch(new Request('http://localhost/', { + method: 'POST', + body: JSON.stringify({ invalid: 'data' }), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + + expect(res.status).toBe(400) + }) + + it('patch /:id should return unauthorized if no user', async () => { + const res = await app.request('/c1', { method: 'PATCH' }) + expect(res.status).toBe(401) + }) + + it('patch /:id should return 400 on invalid body', async () => { + const mockUser = { id: 'user-1' } + const res = await app.fetch(new Request('http://localhost/c1', { + method: 'PATCH', + body: JSON.stringify({ version: 123 }), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + expect(res.status).toBe(400) + }) + + it('patch /:id should return 404 if not found', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue(null) + const res = await app.fetch(new Request('http://localhost/c1', { + method: 'PATCH', + body: JSON.stringify({ version: '2' }), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + expect(res.status).toBe(404) + }) + + it('patch /:id should return 403 if not owner', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-2' }) + const res = await app.fetch(new Request('http://localhost/c1', { + method: 'PATCH', + body: JSON.stringify({ version: '2' }), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + expect(res.status).toBe(403) + }) + + it('patch /:id should update if owner', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-1' }) + characterService.update.mockResolvedValue({ id: 'c1', version: '2' }) + const res = await app.fetch(new Request('http://localhost/c1', { + method: 'PATCH', + body: JSON.stringify({ version: '2' }), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + expect(res.status).toBe(200) + }) + + it('patch /:id should update with empty body', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-1' }) + characterService.update.mockResolvedValue({ id: 'c1' }) + const res = await app.fetch(new Request('http://localhost/c1', { + method: 'PATCH', + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + }), { user: mockUser } as any) + expect(res.status).toBe(200) + }) + + it('delete /:id should return unauthorized if no user', async () => { + const res = await app.request('/c1', { method: 'DELETE' }) + expect(res.status).toBe(401) + }) + + it('delete /:id should return 404 if not found', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue(null) + const res = await app.fetch(new Request('http://localhost/c1', { method: 'DELETE' }), { user: mockUser } as any) + expect(res.status).toBe(404) + }) + + it('delete /:id should return 403 if not owner', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-2' }) + const res = await app.fetch(new Request('http://localhost/c1', { method: 'DELETE' }), { user: mockUser } as any) + expect(res.status).toBe(403) + }) + + it('delete /:id should delete if owner', async () => { + const mockUser = { id: 'user-1' } + characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-1' }) + const res = await app.fetch(new Request('http://localhost/c1', { method: 'DELETE' }), { user: mockUser } as any) + expect(res.status).toBe(204) + }) +}) diff --git a/apps/server/src/routes/characters.ts b/apps/server/src/routes/characters.ts index 174a96e1c..e54851763 100644 --- a/apps/server/src/routes/characters.ts +++ b/apps/server/src/routes/characters.ts @@ -62,7 +62,7 @@ export function createCharacterRoutes(characterService: CharacterService) { throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues) } - const existing = await characterService.findById(id) + const existing = await characterService.findById(id, { withRelations: false }) if (!existing) throw createNotFoundError() if (existing.ownerId !== user.id) @@ -76,7 +76,7 @@ export function createCharacterRoutes(characterService: CharacterService) { const user = c.get('user')! const id = c.req.param('id') - const existing = await characterService.findById(id) + const existing = await characterService.findById(id, { withRelations: false }) if (!existing) throw createNotFoundError() if (existing.ownerId !== user.id) diff --git a/apps/server/src/schemas/characters.ts b/apps/server/src/schemas/characters.ts index f44a0bcc5..21b63b110 100644 --- a/apps/server/src/schemas/characters.ts +++ b/apps/server/src/schemas/characters.ts @@ -6,12 +6,13 @@ import type { CharacterCapabilityConfig } from '../types/character-capability' import { relations } from 'drizzle-orm' import { jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { nanoid } from '../utils/id' import { user } from './accounts' export const character = pgTable( 'characters', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), version: text('version').notNull(), coverUrl: text('cover_url').notNull(), @@ -27,6 +28,7 @@ export const character = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }, ) @@ -36,7 +38,7 @@ export type NewCharacter = InferInsertModel export const avatarModel = pgTable( 'avatar_model', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), name: text('name').notNull(), type: text('type').notNull().$type(), @@ -46,6 +48,7 @@ export const avatarModel = pgTable( config: jsonb('config').notNull().$type(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }, ) @@ -55,7 +58,7 @@ export type NewAvatarModel = InferInsertModel export const characterCapabilities = pgTable( 'character_capabilities', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), type: text('type').notNull().$type(), @@ -70,7 +73,7 @@ export type NewCharacterCapability = InferInsertModel nanoid()), characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), language: text('language').notNull(), @@ -93,6 +96,7 @@ export const characterI18n = pgTable( createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }, ) @@ -104,7 +108,7 @@ type PromptType = 'system' | 'personality' | 'greetings' export const characterPrompts = pgTable( 'character_prompts', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), language: text('language').notNull(), diff --git a/apps/server/src/schemas/chats.ts b/apps/server/src/schemas/chats.ts index aacc6795e..5d9db9aa8 100644 --- a/apps/server/src/schemas/chats.ts +++ b/apps/server/src/schemas/chats.ts @@ -1,11 +1,12 @@ import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { nanoid } from '../utils/id' import { user } from './accounts' export const media = pgTable( 'media', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), url: text('url').notNull(), mimeType: text('mime_type').notNull(), size: integer('size').notNull(), @@ -17,7 +18,7 @@ export const media = pgTable( export const stickers = pgTable( 'stickers', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), url: text('url').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), @@ -27,7 +28,7 @@ export const stickers = pgTable( export const stickerPacks = pgTable( 'sticker_packs', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), name: text('name').notNull(), description: text('description').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), @@ -40,7 +41,7 @@ type ChatType = 'private' | 'bot' | 'group' | 'channel' export const chats = pgTable( 'chats', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), type: text('type').notNull().$type(), @@ -53,7 +54,7 @@ export const chats = pgTable( export const chatMembers = pgTable( 'chat_members', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), }, @@ -62,7 +63,7 @@ export const chatMembers = pgTable( export const messages = pgTable( 'messages', { - id: text('id').primaryKey(), + id: text('id').primaryKey().$defaultFn(() => nanoid()), chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }), senderId: text('sender_id').notNull(), diff --git a/apps/server/src/services/__test__/characters.test.ts b/apps/server/src/services/__test__/characters.test.ts new file mode 100644 index 000000000..4e0330a04 --- /dev/null +++ b/apps/server/src/services/__test__/characters.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createCharacterService } from '../characters' + +import * as schema from '../../schemas/characters' + +describe('characterService', () => { + let db: any + let service: ReturnType + + beforeEach(() => { + db = { + query: { + character: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + }, + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + transaction: vi.fn(async (cb: any) => { + const tx = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + } + return await cb(tx) + }), + } + service = createCharacterService(db) + }) + + it('findById should return a character with relations', async () => { + const mockChar = { id: '1', name: 'Test' } + db.query.character.findFirst.mockResolvedValue(mockChar) + + const result = await service.findById('1') + expect(result).toEqual(mockChar) + expect(db.query.character.findFirst).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.any(Object), + with: { + capabilities: true, + avatarModels: true, + i18n: true, + prompts: true, + }, + })) + }) + + it('findByOwnerId should return user characters', async () => { + const mockChars = [{ id: '1' }] + db.query.character.findMany.mockResolvedValue(mockChars) + + const result = await service.findByOwnerId('user-1') + expect(result).toEqual(mockChars) + expect(db.query.character.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.any(Object), + with: { i18n: true }, + })) + }) + + it('create should handle full character creation in transaction', async () => { + const characterData = { id: 'char-1', name: 'Test' } as any + const insertedChar = { id: 'char-1' } + + db.transaction.mockImplementation(async (cb: any) => { + const tx = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([insertedChar]), + })), + })), + } + return await cb(tx) + }) + + const result = await service.create({ + character: characterData, + capabilities: [{ id: 'cap-1', type: 'llm', config: {} } as any], + }) + + expect(result).toEqual(insertedChar) + expect(db.transaction).toHaveBeenCalled() + }) + + it('create should handle all optional relations', async () => { + const characterData = { id: 'char-1' } as any + const insertedChar = { id: 'char-1' } + + db.transaction.mockImplementation(async (cb: any) => { + const tx = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([insertedChar]), + })), + })), + } + return await cb(tx) + }) + + await service.create({ + character: characterData, + avatarModels: [{ id: 'am-1' } as any], + i18n: [{ id: 'i18n-1' } as any], + prompts: [{ id: 'p-1' } as any], + }) + + expect(db.transaction).toHaveBeenCalled() + }) + + it('update should update character and updatedAt', async () => { + const updateData = { version: '2.0' } + const mockReturning = [{ id: '1', ...updateData }] + + // Setup nested mocks for update chain + const returningMock = vi.fn().mockResolvedValue(mockReturning) + const whereMock = vi.fn(() => ({ returning: returningMock })) + const setMock = vi.fn(() => ({ where: whereMock })) + db.update.mockReturnValue({ set: setMock }) + + const result = await service.update('1', updateData) + expect(result).toEqual(mockReturning) + expect(setMock).toHaveBeenCalledWith(expect.objectContaining({ + ...updateData, + updatedAt: expect.any(Date), + })) + }) + + it('delete should remove character', async () => { + const mockReturning = [{ id: '1' }] + const returningMock = vi.fn().mockResolvedValue(mockReturning) + const whereMock = vi.fn(() => ({ returning: returningMock })) + db.delete.mockReturnValue({ where: whereMock }) + + const result = await service.delete('1') + expect(result).toEqual(mockReturning) + expect(db.delete).toHaveBeenCalledWith(schema.character) + }) +}) diff --git a/apps/server/src/services/characters.ts b/apps/server/src/services/characters.ts index 2207c8167..167b23d2c 100644 --- a/apps/server/src/services/characters.ts +++ b/apps/server/src/services/characters.ts @@ -1,29 +1,38 @@ import type * as fullSchema from '../schemas' import type { Database } from './db' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import * as schema from '../schemas/characters' export function createCharacterService(db: Database) { return { - async findById(id: string) { + async findById(id: string, options: { withRelations?: boolean } = { withRelations: true }) { return await db.query.character.findFirst({ - where: eq(schema.character.id, id), - with: { - capabilities: true, - avatarModels: true, - i18n: true, - prompts: true, - }, + where: and( + eq(schema.character.id, id), + isNull(schema.character.deletedAt), + ), + with: options.withRelations + ? { + capabilities: true, + avatarModels: true, + i18n: true, + prompts: true, + } + : undefined, }) }, async findByOwnerId(ownerId: string) { return await db.query.character.findMany({ - where: eq(schema.character.ownerId, ownerId), + where: and( + eq(schema.character.ownerId, ownerId), + isNull(schema.character.deletedAt), + ), with: { i18n: true, + capabilities: true, }, }) }, @@ -69,13 +78,20 @@ export function createCharacterService(db: Database) { async update(id: string, data: Partial) { return await db.update(schema.character) .set({ ...data, updatedAt: new Date() }) - .where(eq(schema.character.id, id)) + .where(and( + eq(schema.character.id, id), + isNull(schema.character.deletedAt), + )) .returning() }, async delete(id: string) { - return await db.delete(schema.character) - .where(eq(schema.character.id, id)) + return await db.update(schema.character) + .set({ deletedAt: new Date() }) + .where(and( + eq(schema.character.id, id), + isNull(schema.character.deletedAt), + )) .returning() }, } diff --git a/apps/server/src/services/db.ts b/apps/server/src/services/db.ts index 66dba9f9a..d46b6bc70 100644 --- a/apps/server/src/services/db.ts +++ b/apps/server/src/services/db.ts @@ -2,7 +2,7 @@ import postgres from 'postgres' import { drizzle } from 'drizzle-orm/postgres-js' -export type Database = Record> = ReturnType> +export type Database = Record> = ReturnType> export function createDrizzle>(dsn: string, schema?: TSchema) { return drizzle(postgres(dsn), { schema }) diff --git a/apps/server/src/utils/id.ts b/apps/server/src/utils/id.ts new file mode 100644 index 000000000..dad63186c --- /dev/null +++ b/apps/server/src/utils/id.ts @@ -0,0 +1,12 @@ +/** + * Simple nanoid implementation to avoid dependencies + */ +export function nanoid(size = 21): string { + const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-' + let id = '' + const bytes = crypto.getRandomValues(new Uint8Array(size)) + for (let i = 0; i < size; i++) { + id += alphabet[bytes[i] % alphabet.length] + } + return id +} diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json index 843863b11..1e8e22b61 100644 --- a/apps/stage-web/package.json +++ b/apps/stage-web/package.json @@ -63,6 +63,7 @@ "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", "gpuu": "^1.0.6", + "hono": "catalog:", "html2canvas": "^1.4.1", "jszip": "^3.10.1", "localforage": "^1.10.0", diff --git a/apps/stage-web/src/composables/api.ts b/apps/stage-web/src/composables/api.ts index 6be122020..3591e22b9 100644 --- a/apps/stage-web/src/composables/api.ts +++ b/apps/stage-web/src/composables/api.ts @@ -1,16 +1,21 @@ -import { ofetch } from 'ofetch' +import type { AppType } from '../../../server/src/app' + +import { hc } from 'hono/client' import { useAuthStore } from '../stores/auth' import { API_SERVER_URL } from './auth' -export function doRequest(url: string, options: RequestInit = {}) { - const authStore = useAuthStore() - return ofetch(url, { - baseURL: API_SERVER_URL, - ...options, - headers: { - ...options.headers, - Authorization: `Bearer ${authStore.authToken}`, - }, - }) -} +export const client = hc(API_SERVER_URL, { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + const authStore = useAuthStore() + const headers = new Headers(init?.headers) + if (authStore.authToken) { + headers.set('Authorization', `Bearer ${authStore.authToken}`) + } + return fetch(input, { + ...init, + headers, + credentials: 'include', // Send cookies with request (for sessions, etc) + }) + }, +}) diff --git a/apps/stage-web/src/pages/settings/characters/components/CharacterCreate.vue b/apps/stage-web/src/pages/settings/characters/components/CharacterCreate.vue new file mode 100644 index 000000000..8d3fc2759 --- /dev/null +++ b/apps/stage-web/src/pages/settings/characters/components/CharacterCreate.vue @@ -0,0 +1,22 @@ + + + + diff --git a/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue b/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue new file mode 100644 index 000000000..cf6206054 --- /dev/null +++ b/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue @@ -0,0 +1,294 @@ + + + diff --git a/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue b/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue new file mode 100644 index 000000000..8d45805c8 --- /dev/null +++ b/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/stage-web/src/pages/settings/characters/index.vue b/apps/stage-web/src/pages/settings/characters/index.vue new file mode 100644 index 000000000..4aad84f8d --- /dev/null +++ b/apps/stage-web/src/pages/settings/characters/index.vue @@ -0,0 +1,134 @@ + + + + + +meta: + layout: settings + title: Characters + diff --git a/apps/stage-web/src/stores/characters.ts b/apps/stage-web/src/stores/characters.ts new file mode 100644 index 000000000..0037d7a12 --- /dev/null +++ b/apps/stage-web/src/stores/characters.ts @@ -0,0 +1,147 @@ +import type { Character, CreateCharacterPayload, UpdateCharacterPayload } from '../types/character' + +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { client } from '../composables/api' + +export const useCharacterStore = defineStore('characters', () => { + const characters = ref>(new Map()) + const isLoading = ref(false) + const error = ref(null) + + async function fetchList() { + isLoading.value = true + error.value = null + try { + const res = await client.api.characters.$get() + if (!res.ok) { + throw new Error('Failed to fetch characters') + } + const data = await res.json() + + characters.value.clear() + for (const char of data) { + characters.value.set(char.id, char as Character) + } + } + catch (err) { + error.value = err + throw err + } + finally { + isLoading.value = false + } + } + + async function fetchById(id: string) { + isLoading.value = true + error.value = null + try { + const res = await client.api.characters[':id'].$get({ + param: { id }, + }) + if (!res.ok) { + throw new Error('Failed to fetch character') + } + const data = await res.json() + + characters.value.set(data.id, data as Character) + return data + } + catch (err) { + error.value = err + throw err + } + finally { + isLoading.value = false + } + } + + async function create(payload: CreateCharacterPayload) { + isLoading.value = true + error.value = null + try { + const res = await client.api.characters.$post({ + json: payload, + }) + if (!res.ok) { + throw new Error('Failed to create character') + } + const data = await res.json() + + characters.value.set(data.id, data as Character) + return data + } + catch (err) { + error.value = err + throw err + } + finally { + isLoading.value = false + } + } + + async function update(id: string, payload: UpdateCharacterPayload) { + isLoading.value = true + error.value = null + try { + const res = await client.api.characters[':id'].$patch({ + param: { id }, + json: payload, + }) + if (!res.ok) { + throw new Error('Failed to update character') + } + const data = await res.json() + + characters.value.set(data.id, data as Character) + return data + } + catch (err) { + error.value = err + throw err + } + finally { + isLoading.value = false + } + } + + async function remove(id: string) { + isLoading.value = true + error.value = null + try { + const res = await client.api.characters[':id'].$delete({ + param: { id }, + }) + if (!res.ok) { + throw new Error('Failed to delete character') + } + + characters.value.delete(id) + } + catch (err) { + error.value = err + throw err + } + finally { + isLoading.value = false + } + } + + function getCharacter(id: string) { + return characters.value.get(id) + } + + return { + characters, + isLoading, + error, + fetchList, + fetchById, + create, + update, + remove, + getCharacter, + } +}) diff --git a/apps/stage-web/src/types/character.ts b/apps/stage-web/src/types/character.ts new file mode 100644 index 000000000..9335df745 --- /dev/null +++ b/apps/stage-web/src/types/character.ts @@ -0,0 +1,168 @@ +import type { InferOutput } from 'valibot' + +import { array, date, literal, number, object, optional, pipe, string, transform, union } from 'valibot' + +// --- Enums & Configs --- + +export const AvatarModelConfigSchema = object({ + vrm: optional(object({ + urls: array(string()), + })), + live2d: optional(object({ + urls: array(string()), + })), +}) + +export const CharacterCapabilityConfigSchema = object({ + apiKey: string(), + apiBaseUrl: string(), + llm: optional(object({ + temperature: number(), + model: string(), + })), + tts: optional(object({ + ssml: string(), + voiceId: string(), + speed: number(), + pitch: number(), + })), + vlm: optional(object({ + image: string(), + })), + asr: optional(object({ + audio: string(), + })), +}) + +const CharacterCapabilityTypeSchema = union([ + literal('llm'), + literal('tts'), + literal('vlm'), + literal('asr'), +]) + +const AvatarModelTypeSchema = union([ + literal('vrm'), + literal('live2d'), +]) + +const PromptTypeSchema = union([ + literal('system'), + literal('personality'), + literal('greetings'), +]) + +const DateSchema = pipe( + union([string(), date()]), + transform(v => new Date(v)), +) + +// --- Base Entities (mimicking database tables) --- + +export const CharacterBaseSchema = object({ + id: string(), + version: string(), + coverUrl: string(), + creatorId: string(), + ownerId: string(), + characterId: string(), + createdAt: DateSchema, + updatedAt: DateSchema, +}) + +export const CharacterCapabilitySchema = object({ + id: string(), + characterId: string(), + type: CharacterCapabilityTypeSchema, + config: CharacterCapabilityConfigSchema, +}) + +export const AvatarModelSchema = object({ + id: string(), + characterId: string(), + name: string(), + type: AvatarModelTypeSchema, + description: string(), + config: AvatarModelConfigSchema, + createdAt: DateSchema, + updatedAt: DateSchema, +}) + +export const CharacterI18nSchema = object({ + id: string(), + characterId: string(), + language: string(), + name: string(), + description: string(), + tags: array(string()), + createdAt: DateSchema, + updatedAt: DateSchema, +}) + +export const CharacterPromptSchema = object({ + id: string(), + characterId: string(), + language: string(), + type: PromptTypeSchema, + content: string(), +}) + +// --- Aggregated Character (with relations) --- + +export const CharacterWithRelationsSchema = object({ + ...CharacterBaseSchema.entries, + capabilities: array(CharacterCapabilitySchema), + avatarModels: array(AvatarModelSchema), + i18n: array(CharacterI18nSchema), + prompts: array(CharacterPromptSchema), +}) + +// --- API Request Schemas --- + +export const CreateCharacterSchema = object({ + character: object({ + version: string(), + coverUrl: string(), + characterId: string(), + // creatorId & ownerId are handled by server + }), + capabilities: optional(array(object({ + type: CharacterCapabilityTypeSchema, + config: CharacterCapabilityConfigSchema, + }))), + avatarModels: optional(array(object({ + name: string(), + type: AvatarModelTypeSchema, + description: string(), + config: AvatarModelConfigSchema, + }))), + i18n: optional(array(object({ + language: string(), + name: string(), + description: string(), + tags: array(string()), + }))), + prompts: optional(array(object({ + language: string(), + type: PromptTypeSchema, + content: string(), + }))), +}) + +export const UpdateCharacterSchema = object({ + version: optional(string()), + coverUrl: optional(string()), + characterId: optional(string()), +}) + +// --- Type Exports --- + +export type Character = InferOutput +export type CharacterBase = InferOutput +export type CharacterCapability = InferOutput +export type AvatarModel = InferOutput +export type CharacterI18n = InferOutput +export type CharacterPrompt = InferOutput + +export type CreateCharacterPayload = InferOutput +export type UpdateCharacterPayload = InferOutput diff --git a/packages/stage-layouts/src/layouts/settings.vue b/packages/stage-layouts/src/layouts/settings.vue index c4d693e8f..d406f2212 100644 --- a/packages/stage-layouts/src/layouts/settings.vue +++ b/packages/stage-layouts/src/layouts/settings.vue @@ -23,6 +23,10 @@ const routeHeaderMetadataMap = computed(() => { subtitle: t('settings.title'), title: t('settings.pages.card.title'), }, + '/settings/characters': { + subtitle: t('settings.title'), + title: 'Characters', + }, '/settings/system': { subtitle: t('settings.title'), title: t('settings.pages.system.title'), diff --git a/packages/ui/src/components/Form/Field/FieldInput.vue b/packages/ui/src/components/Form/Field/FieldInput.vue index edf957362..bd03a5797 100644 --- a/packages/ui/src/components/Form/Field/FieldInput.vue +++ b/packages/ui/src/components/Form/Field/FieldInput.vue @@ -24,7 +24,7 @@ const modelValue = defineModel({ required: false }) {{ props.label }} - * + *
diff --git a/packages/ui/src/components/Misc/Button.vue b/packages/ui/src/components/Misc/Button.vue index b2c0947a2..ff4580c7e 100644 --- a/packages/ui/src/components/Misc/Button.vue +++ b/packages/ui/src/components/Misc/Button.vue @@ -3,7 +3,7 @@ import { BidirectionalTransition } from '@proj-airi/ui' import { computed } from 'vue' // Define button variants for better type safety and maintainability -type ButtonVariant = 'primary' | 'secondary' | 'secondary-muted' | 'danger' | 'caution' | 'ghost' +type ButtonVariant = 'primary' | 'secondary' | 'secondary-muted' | 'danger' | 'caution' | 'pure' | 'ghost' type ButtonTheme = 'default' @@ -112,6 +112,11 @@ const variantClasses: Record = { } // Base classes that are always applied -const baseClasses = computed(() => [ - 'font-medium outline-none', - 'transition-all duration-200 ease-in-out', - 'disabled:cursor-not-allowed disabled:opacity-50', - props.block ? 'w-full' : '', - sizeClasses[props.size], - ...variantClasses[props.variant][props.theme].default, - props.toggled - ? variantClasses[props.variant][props.theme].toggled || '' - : variantClasses[props.variant][props.theme].nonToggled || '', - isDisabled.value ? 'opacity-50 cursor-not-allowed' : '', -]) +const baseClasses = computed(() => { + const variant = variantClasses[props.variant] || variantClasses.primary + const theme = variant[props.theme] || variant.default + + return [ + 'rounded-lg font-medium outline-none', + 'transition-all duration-200 ease-in-out', + 'disabled:cursor-not-allowed disabled:opacity-50', + 'backdrop-blur-md', + props.block ? 'w-full' : '', + sizeClasses[props.size], + theme.default, + props.toggled ? theme.toggled || '' : theme.nonToggled || '', + { 'opacity-50 cursor-not-allowed': isDisabled.value }, + 'focus:ring-2', + ] +})