From 996eb4602ff42d2caa62bfef02ed3d8c54aa12db Mon Sep 17 00:00:00 2001 From: faramix Date: Mon, 30 Mar 2026 15:31:54 +0200 Subject: [PATCH 01/30] feat: Add Candy-style companion features Character Schema: - Add visibility, nsfwEnabled, nsfwLevel, relationshipMode - Add personaProfile with personality, scenario, speakingStyle - Add starterMessages and boundaries fields - Migration 0008_lazy_wallflower.sql Frontend Updates: - CharacterDialog: New persona tab with NSFW/relationship controls - CharacterItem: Show relationship/NSFW badges - v2/index.vue: Better explore page with filters - v2/[id].vue: New character detail page with profile view Types & Stores: - Updated CharacterBaseSchema with all new fields - CharacterPersonaProfile interface added - Local-first character store compatibility Co-Authored-By: Claude Opus 4.6 --- apps/server/drizzle/0008_lazy_wallflower.sql | 14 ++ apps/server/drizzle/meta/_journal.json | 9 +- .../src/routes/characters/route.test.ts | 38 ++- apps/server/src/routes/characters/schema.ts | 46 +++- apps/server/src/schemas/characters.ts | 22 +- .../src/services/tests/characters.test.ts | 33 ++- .../characters/components/CharacterDialog.vue | 126 ++++++++++ .../characters/components/CharacterItem.vue | 22 +- packages/stage-pages/src/pages/v2/[id].vue | 222 ++++++++++++++++++ packages/stage-pages/src/pages/v2/index.vue | 104 ++++++-- packages/stage-ui/src/stores/characters.ts | 15 ++ packages/stage-ui/src/types/character.ts | 65 ++++- 12 files changed, 684 insertions(+), 32 deletions(-) create mode 100644 apps/server/drizzle/0008_lazy_wallflower.sql create mode 100644 packages/stage-pages/src/pages/v2/[id].vue diff --git a/apps/server/drizzle/0008_lazy_wallflower.sql b/apps/server/drizzle/0008_lazy_wallflower.sql new file mode 100644 index 0000000000..2f45b7ffce --- /dev/null +++ b/apps/server/drizzle/0008_lazy_wallflower.sql @@ -0,0 +1,14 @@ +ALTER TABLE "characters" +ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL; +--> statement-breakpoint +ALTER TABLE "characters" +ADD COLUMN "nsfw_enabled" boolean DEFAULT false NOT NULL; +--> statement-breakpoint +ALTER TABLE "characters" +ADD COLUMN "nsfw_level" text DEFAULT 'none' NOT NULL; +--> statement-breakpoint +ALTER TABLE "characters" +ADD COLUMN "relationship_mode" text DEFAULT 'companion' NOT NULL; +--> statement-breakpoint +ALTER TABLE "characters" +ADD COLUMN "persona_profile" jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 12cea9e473..2732d7165c 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1774632446757, "tag": "0007_red_nicolaos", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1774800000000, + "tag": "0008_lazy_wallflower", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/server/src/routes/characters/route.test.ts b/apps/server/src/routes/characters/route.test.ts index 6a6da29b96..15584919c5 100644 --- a/apps/server/src/routes/characters/route.test.ts +++ b/apps/server/src/routes/characters/route.test.ts @@ -67,7 +67,22 @@ describe('characterRoutes', () => { it('post / should create character with cover', async () => { const payload = { - character: { version: '1', coverUrl: 'url', characterId: 'cid' }, + character: { + version: '1', + coverUrl: 'url', + characterId: 'cid', + visibility: 'public', + nsfwEnabled: true, + nsfwLevel: 'suggestive', + relationshipMode: 'romance', + personaProfile: { + personality: 'Playful', + scenario: 'Late-night texting', + speakingStyle: 'Teasing', + starterMessages: ['You took long enough.'], + memoryProfile: 'deep', + }, + }, i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }], cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' }, } @@ -84,6 +99,12 @@ describe('characterRoutes', () => { const char = await characterService.findById(data.id) expect(char?.cover?.foregroundUrl).toBe('fg') + expect(char?.visibility).toBe('public') + expect(char?.nsfwEnabled).toBe(true) + expect(char?.personaProfile).toMatchObject({ + personality: 'Playful', + memoryProfile: 'deep', + }) }) it('get / should return created character', async () => { @@ -118,13 +139,26 @@ describe('characterRoutes', () => { const res = await app.fetch(new Request(`http://localhost/${charId}`, { method: 'PATCH', - body: JSON.stringify({ version: '2.0' }), + body: JSON.stringify({ + version: '2.0', + nsfwLevel: 'explicit', + relationshipMode: 'roleplay', + personaProfile: { + personality: 'Direct', + boundaries: ['no minors'], + }, + }), headers: { 'Content-Type': 'application/json' }, }), { user: testUser } as any) expect(res.status).toBe(200) const char = await characterService.findById(charId) expect(char?.version).toBe('2.0') + expect(char?.nsfwLevel).toBe('explicit') + expect(char?.relationshipMode).toBe('roleplay') + expect(char?.personaProfile).toMatchObject({ + personality: 'Direct', + }) }) it('patch /:id should return 403 if not owner', async () => { diff --git a/apps/server/src/routes/characters/schema.ts b/apps/server/src/routes/characters/schema.ts index 071375f854..913cdd73e0 100644 --- a/apps/server/src/routes/characters/schema.ts +++ b/apps/server/src/routes/characters/schema.ts @@ -1,5 +1,5 @@ import { createInsertSchema, createSelectSchema } from 'drizzle-valibot' -import { array, literal, number, object, optional, pipe, string, transform, union } from 'valibot' +import { array, boolean, literal, number, object, optional, pipe, string, transform, union } from 'valibot' import * as schema from '../../schemas/characters' @@ -45,6 +45,40 @@ const AvatarModelTypeSchema = union([ literal('live2d'), ]) +const CharacterVisibilitySchema = union([ + literal('private'), + literal('public'), + literal('unlisted'), +]) + +const CharacterNsfwLevelSchema = union([ + literal('none'), + literal('suggestive'), + literal('explicit'), +]) + +const CharacterRelationshipModeSchema = union([ + literal('companion'), + literal('romance'), + literal('roleplay'), +]) + +const CharacterMemoryProfileSchema = union([ + literal('light'), + literal('standard'), + literal('deep'), +]) + +export const CharacterPersonaProfileSchema = object({ + personality: optional(string()), + scenario: optional(string()), + speakingStyle: optional(string()), + traits: optional(array(string())), + boundaries: optional(array(string())), + starterMessages: optional(array(string())), + memoryProfile: optional(CharacterMemoryProfileSchema), +}) + export const CharacterSchema = createSelectSchema(schema.character) export const InsertCharacterSchema = createInsertSchema(schema.character) @@ -77,6 +111,11 @@ export const CreateCharacterSchema = object({ avatarUrl: optional(string()), creatorRole: optional(string()), priceCredit: optional(string()), + visibility: optional(CharacterVisibilitySchema), + nsfwEnabled: optional(boolean()), + nsfwLevel: optional(CharacterNsfwLevelSchema), + relationshipMode: optional(CharacterRelationshipModeSchema), + personaProfile: optional(CharacterPersonaProfileSchema), }), cover: optional(createInsertSchema(schema.characterCovers, { characterId: optional(string()), @@ -109,6 +148,11 @@ export const UpdateCharacterSchema = createInsertSchema(schema.character, { avatarUrl: optional(string()), creatorRole: optional(string()), priceCredit: optional(string()), + visibility: optional(CharacterVisibilitySchema), + nsfwEnabled: optional(boolean()), + nsfwLevel: optional(CharacterNsfwLevelSchema), + relationshipMode: optional(CharacterRelationshipModeSchema), + personaProfile: optional(CharacterPersonaProfileSchema), creatorId: optional(string()), ownerId: optional(string()), characterId: optional(string()), diff --git a/apps/server/src/schemas/characters.ts b/apps/server/src/schemas/characters.ts index a7482ea3a7..42af343274 100644 --- a/apps/server/src/schemas/characters.ts +++ b/apps/server/src/schemas/characters.ts @@ -4,7 +4,7 @@ import type { AvatarModelConfig } from '../types/character-avatar-model' import type { CharacterCapabilityConfig } from '../types/character-capability' import { relations } from 'drizzle-orm' -import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { boolean, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' import { user } from './accounts' @@ -25,6 +25,11 @@ export const character = pgTable( avatarUrl: text('avatar_url'), creatorRole: text('creator_role'), priceCredit: text('price_credit').default('0').notNull(), + visibility: text('visibility').$type().default('private').notNull(), + nsfwEnabled: boolean('nsfw_enabled').default(false).notNull(), + nsfwLevel: text('nsfw_level').$type().default('none').notNull(), + relationshipMode: text('relationship_mode').$type().default('companion').notNull(), + personaProfile: jsonb('persona_profile').$type().default({}).notNull(), likesCount: integer('likes_count').default(0).notNull(), bookmarksCount: integer('bookmarks_count').default(0).notNull(), @@ -40,6 +45,21 @@ export const character = pgTable( export type Character = InferSelectModel export type NewCharacter = InferInsertModel +export type CharacterVisibility = 'private' | 'public' | 'unlisted' +export type CharacterNsfwLevel = 'none' | 'suggestive' | 'explicit' +export type CharacterRelationshipMode = 'companion' | 'romance' | 'roleplay' +export type CharacterMemoryProfile = 'light' | 'standard' | 'deep' + +export interface CharacterPersonaProfile { + personality?: string + scenario?: string + speakingStyle?: string + traits?: string[] + boundaries?: string[] + starterMessages?: string[] + memoryProfile?: CharacterMemoryProfile +} + export const characterCovers = pgTable( 'character_covers', { diff --git a/apps/server/src/services/tests/characters.test.ts b/apps/server/src/services/tests/characters.test.ts index 67c6186467..69b530013b 100644 --- a/apps/server/src/services/tests/characters.test.ts +++ b/apps/server/src/services/tests/characters.test.ts @@ -33,6 +33,18 @@ describe('characterService', () => { characterId: 'cid', ownerId: testUser.id, creatorId: testUser.id, + visibility: 'public' as const, + nsfwEnabled: true, + nsfwLevel: 'suggestive' as const, + relationshipMode: 'romance' as const, + personaProfile: { + personality: 'Playful and teasing', + speakingStyle: 'Flirty', + traits: ['witty', 'warm'], + boundaries: ['no violence'], + starterMessages: ['Missed me?'], + memoryProfile: 'deep' as const, + }, } const result = await service.create({ @@ -46,6 +58,13 @@ describe('characterService', () => { const found = await service.findById('char-1') expect(found?.i18n[0].name).toBe('Aster') expect(found?.cover?.foregroundUrl).toBe('fg') + expect(found?.visibility).toBe('public') + expect(found?.nsfwEnabled).toBe(true) + expect(found?.relationshipMode).toBe('romance') + expect(found?.personaProfile).toMatchObject({ + personality: 'Playful and teasing', + memoryProfile: 'deep', + }) }) it('findAll should return characters with relations', async () => { @@ -93,9 +112,21 @@ describe('characterService', () => { }) it('update should update character fields', async () => { - await service.update('char-1', { version: '2.0' }) + await service.update('char-1', { + version: '2.0', + nsfwLevel: 'explicit', + personaProfile: { + personality: 'More intense', + memoryProfile: 'standard', + }, + }) const char = await service.findById('char-1') expect(char?.version).toBe('2.0') + expect(char?.nsfwLevel).toBe('explicit') + expect(char?.personaProfile).toMatchObject({ + personality: 'More intense', + memoryProfile: 'standard', + }) }) it('delete should soft delete character', async () => { diff --git a/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue b/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue index 5912fa75e1..1c8ed84303 100644 --- a/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue +++ b/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue @@ -32,8 +32,17 @@ const form = reactive({ characterId: '', version: '1.0.0', coverUrl: '', + visibility: 'private' as 'private' | 'public' | 'unlisted', + nsfwEnabled: false, + nsfwLevel: 'none' as 'none' | 'suggestive' | 'explicit', + relationshipMode: 'companion' as 'companion' | 'romance' | 'roleplay', name: '', description: '', + personality: '', + scenario: '', + speakingStyle: '', + starterMessages: '', + boundaries: '', // Capability: LLM llmModel: '', @@ -54,8 +63,17 @@ watch(() => props.character, (char) => { form.characterId = char.characterId form.version = char.version form.coverUrl = char.coverUrl + form.visibility = char.visibility + form.nsfwEnabled = char.nsfwEnabled + form.nsfwLevel = char.nsfwLevel + form.relationshipMode = char.relationshipMode form.name = i18n?.name || '' form.description = i18n?.description || '' + form.personality = char.personaProfile?.personality || '' + form.scenario = char.personaProfile?.scenario || '' + form.speakingStyle = char.personaProfile?.speakingStyle || '' + form.starterMessages = char.personaProfile?.starterMessages?.join('\n') || '' + form.boundaries = char.personaProfile?.boundaries?.join('\n') || '' form.llmModel = llm?.config.llm?.model || '' form.llmTemperature = llm?.config.llm?.temperature || 0.7 @@ -68,8 +86,17 @@ watch(() => props.character, (char) => { form.characterId = '' form.version = '1.0.0' form.coverUrl = '' + form.visibility = 'private' + form.nsfwEnabled = false + form.nsfwLevel = 'none' + form.relationshipMode = 'companion' form.name = '' form.description = '' + form.personality = '' + form.scenario = '' + form.speakingStyle = '' + form.starterMessages = '' + form.boundaries = '' form.llmModel = 'gpt-4o-mini' form.llmTemperature = 0.7 form.ttsVoiceId = '' @@ -90,6 +117,24 @@ async function handleSubmit() { characterId: form.characterId, version: form.version, coverUrl: form.coverUrl, + visibility: form.visibility, + nsfwEnabled: form.nsfwEnabled, + nsfwLevel: form.nsfwLevel, + relationshipMode: form.relationshipMode, + personaProfile: { + personality: form.personality || undefined, + scenario: form.scenario || undefined, + speakingStyle: form.speakingStyle || undefined, + starterMessages: form.starterMessages + .split('\n') + .map(item => item.trim()) + .filter(Boolean), + boundaries: form.boundaries + .split('\n') + .map(item => item.trim()) + .filter(Boolean), + memoryProfile: form.relationshipMode === 'romance' ? 'deep' : 'standard', + }, }, i18n: [{ language: 'en', @@ -151,6 +196,24 @@ async function handleSubmit() { characterId: form.characterId, version: form.version, coverUrl: form.coverUrl, + visibility: form.visibility, + nsfwEnabled: form.nsfwEnabled, + nsfwLevel: form.nsfwLevel, + relationshipMode: form.relationshipMode, + personaProfile: { + personality: form.personality || undefined, + scenario: form.scenario || undefined, + speakingStyle: form.speakingStyle || undefined, + starterMessages: form.starterMessages + .split('\n') + .map(item => item.trim()) + .filter(Boolean), + boundaries: form.boundaries + .split('\n') + .map(item => item.trim()) + .filter(Boolean), + memoryProfile: form.relationshipMode === 'romance' ? 'deep' : 'standard', + }, }) // Capabilities/I18n update not supported in simple UpdateCharacterSchema yet? // Checking types/character.ts: UpdateCharacterSchema only has version, coverUrl, characterId. @@ -179,6 +242,7 @@ async function handleSubmit() { const activeTab = ref('identity') const tabs = [ { id: 'identity', label: 'Identity', icon: 'i-solar:user-id-bold-duotone' }, + { id: 'persona', label: 'Persona', icon: 'i-solar:mask-happly-bold-duotone' }, { id: 'capabilities', label: 'Capabilities', icon: 'i-solar:cpu-bolt-bold-duotone' }, // { id: 'models', label: 'Models', icon: 'i-solar:box-minimalistic-bold-duotone' }, ] @@ -233,6 +297,68 @@ const isOpen = computed({ + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ + +
+ + +
+ + + + +
diff --git a/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue b/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue index 15f1567cf1..b0cdb07f53 100644 --- a/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue +++ b/apps/stage-web/src/pages/settings/characters/components/CharacterItem.vue @@ -26,6 +26,14 @@ const i18n = computed(() => { const name = computed(() => i18n.value?.name || 'Unknown') const description = computed(() => i18n.value?.description || '') +const personaSummary = computed(() => props.character.personaProfile?.personality || description.value) +const badges = computed(() => { + const items = [props.character.relationshipMode] + if (props.character.nsfwEnabled) + items.push(props.character.nsfwLevel) + items.push(props.character.visibility) + return items +}) const consciousnessModel = computed(() => { if (!props.character.capabilities) @@ -67,8 +75,18 @@ const voiceModel = computed(() => { -

- {{ description }} +

+ + {{ badge }} + +
+ +

+ {{ personaSummary }}

diff --git a/packages/stage-pages/src/pages/v2/[id].vue b/packages/stage-pages/src/pages/v2/[id].vue new file mode 100644 index 0000000000..da302bf24a --- /dev/null +++ b/packages/stage-pages/src/pages/v2/[id].vue @@ -0,0 +1,222 @@ + + + diff --git a/packages/stage-pages/src/pages/v2/index.vue b/packages/stage-pages/src/pages/v2/index.vue index cfa3c474e4..9e9fe75eb7 100644 --- a/packages/stage-pages/src/pages/v2/index.vue +++ b/packages/stage-pages/src/pages/v2/index.vue @@ -2,10 +2,13 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' import { useCharacterStore } from '@proj-airi/stage-ui/stores/characters' import { Button } from '@proj-airi/ui' -import { computed, onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' +import { useRouter } from 'vue-router' const characterStore = useCharacterStore() const authStore = useAuthStore() +const router = useRouter() +const activeFilter = ref<'all' | 'companion' | 'romance' | 'roleplay' | 'nsfw'>('all') const coverImage = new URL('../../../../stage-ui/src/components/menu/relu.avif', import.meta.url).href const characterAvatarImage = new URL('../../../../stage-ui/src/assets/live2d/models/hiyori/preview.png', import.meta.url).href @@ -38,7 +41,15 @@ onMounted(() => { characterStore.fetchList(true) }) -const characters = computed(() => Array.from(characterStore.characters.values()).map((char) => { +const characters = computed(() => Array.from(characterStore.characters.values()) + .filter((char) => { + if (activeFilter.value === 'all') + return true + if (activeFilter.value === 'nsfw') + return char.nsfwEnabled + return char.relationshipMode === activeFilter.value + }) + .map((char) => { const i18n = char.i18n?.[0] || { name: 'Unknown', tagline: '', description: '' } return { @@ -54,17 +65,61 @@ const characters = computed(() => Array.from(characterStore.characters.values()) likes: char.likesCount, bookmarks: char.bookmarksCount, forks: char.forksCount, + visibility: char.visibility, + nsfwEnabled: char.nsfwEnabled, + nsfwLevel: char.nsfwLevel, + relationshipMode: char.relationshipMode, + personality: char.personaProfile?.personality, + scenario: char.personaProfile?.scenario, liked: char.likes?.some(l => l.userId === authStore.user?.id), bookmarked: char.bookmarks?.some(b => b.userId === authStore.user?.id), priceCredit: char.priceCredit, } })) + +const filters = [ + { id: 'all', label: 'All' }, + { id: 'companion', label: 'Companion' }, + { id: 'romance', label: 'Romance' }, + { id: 'roleplay', label: 'Roleplay' }, + { id: 'nsfw', label: 'NSFW' }, +] as const