diff --git a/src/common/typebox.ts b/src/common/typebox.ts index 79a081b..0422463 100644 --- a/src/common/typebox.ts +++ b/src/common/typebox.ts @@ -4,8 +4,8 @@ const GlobalNumber = Number; const GlobalArray = Array; export namespace Type { - export function String(args: { description?: string, enum?: string[] } = {}) { - const result: TString = { + export function String(args: { description?: string, enum?: S[] } = {}) { + const result: TString = { type: 'string', }; if (args.enum) { @@ -17,8 +17,8 @@ export namespace Type { return result; } - export function Number(args: { description?: string, enum?: number[] } = {}) { - const result: TNumber = { + export function Number(args: { description?: string, enum?: N[] } = {}) { + const result: TNumber = { type: 'number', }; if (args.enum) { @@ -30,8 +30,8 @@ export namespace Type { return result; } - export function Integer(args: { description?: string, enum?: number[] } = {}) { - const result: TNumber = { + export function Integer(args: { description?: string, enum?: N[] } = {}) { + const result: TNumber = { type: 'integer', }; if (args.enum) { @@ -70,7 +70,7 @@ export namespace Type { return result as unknown as TOptional; } - export function Object = Record>(properties: T, args: { description?: string } = {}) { + export function Object(properties: T, args: { description?: string } = {}) { const result: TObject = { type: 'object', properties, @@ -83,13 +83,9 @@ export namespace Type { return result; } - type TEnumType = - T extends string ? TString : - T extends number ? TNumber : never; - - export function Enum(items: Static[], args?: { description?: string }): T; - export function Enum(items: Static[], args?: { description?: string }): T; - export function Enum(items: Static[], args: { description?: string } = {}) { + export function Enum>(items: S[], args?: { description?: string }): T; + export function Enum>(items: N[], args?: { description?: string }): T; + export function Enum(items: (number | string)[], args: { description?: string } = {}) { if (typeof items?.[0] === 'number') { return Number({ enum: items.map(item => +item!), ...args }); } @@ -195,7 +191,9 @@ export interface TArray { items: T; } -export interface TObject = Record> { +export type TProperties = Record; + +export interface TObject { type: 'object'; description?: string; properties: T; @@ -218,7 +216,7 @@ export interface TOptionalArray extends TArray { [optional]: true; } -export interface TOptionalObject = Record> extends TObject { +export interface TOptionalObject extends TObject { [optional]: true; } @@ -236,15 +234,15 @@ export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptiona type Prettify = { [K in keyof T]: T[K] } & {}; -type RequiredKeys> = { +type RequiredKeys = { [K in keyof T]: IsOptional extends true ? never : K }[keyof T]; -type OptionalKeys> = { +type OptionalKeys = { [K in keyof T]: IsOptional extends true ? K : never }[keyof T]; -type StaticObject> = Prettify< +type StaticObject = Prettify< { [K in RequiredKeys]: Static } & { [K in OptionalKeys]?: Static } >; diff --git a/src/games/storywriter/assets/character-editor.module.css b/src/games/storywriter/assets/character-editor.module.css index ef6a781..2e4278d 100644 --- a/src/games/storywriter/assets/character-editor.module.css +++ b/src/games/storywriter/assets/character-editor.module.css @@ -257,6 +257,23 @@ } } +.select { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + color: var(--text); + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--accent); + } +} + .smallButton { padding: 4px 10px; background: var(--bg); diff --git a/src/games/storywriter/components/character-editor.tsx b/src/games/storywriter/components/character-editor.tsx index 5f9773a..4200b00 100644 --- a/src/games/storywriter/components/character-editor.tsx +++ b/src/games/storywriter/components/character-editor.tsx @@ -1,4 +1,4 @@ -import { useAppState, type Character } from "../contexts/state"; +import { CharacterRole, useAppState, type Character } from "../contexts/state"; import { useState } from "preact/hooks"; import styles from '../assets/character-editor.module.css'; import LLM from "../utils/llm"; @@ -22,6 +22,7 @@ export const CharacterEditor = () => { character: { id: crypto.randomUUID(), name: 'New Character', + role: CharacterRole.Main, nicknames: [], shortDescription: '', description: '', @@ -173,6 +174,23 @@ export const CharacterEditor = () => { )} +
+
Role
+ +
+
Description
> } + | { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial> } | { type: 'DELETE_CHARACTER'; storyId: string; characterId: string } | { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] } | { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial } diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 2f7bc0c..9a75689 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -1,6 +1,6 @@ import LLM from "./llm"; import Chapters from "./chapters"; -import { type AppState, LocationScale } from "../contexts/state"; +import { type AppState, CharacterRole, LocationScale } from "../contexts/state"; import { Tools } from "./tools"; namespace Prompt { @@ -110,6 +110,71 @@ namespace Prompt { return renderSlots(slots); } + /** + * Character detail configuration based on role. + * Determines how much information to include in the system prompt for each character. + * AI agents can retrieve full character details on-demand via the get_character tool. + */ + interface CharacterDetailConfig { + includeRole: boolean; + includeNicknames: boolean; + includeShortDescription: boolean; + includeFullDescription: boolean; + includeRelations: boolean; + } + + const CHARACTER_ROLE_DETAIL: Record = { + [CharacterRole.Protagonist]: { + includeRole: true, + includeNicknames: true, + includeShortDescription: false, // omitted if full description present + includeFullDescription: true, + includeRelations: true, + }, + [CharacterRole.Main]: { + includeRole: true, + includeNicknames: true, + includeShortDescription: false, + includeFullDescription: true, + includeRelations: true, + }, + [CharacterRole.Antagonist]: { + includeRole: true, + includeNicknames: true, + includeShortDescription: false, + includeFullDescription: true, + includeRelations: true, + }, + [CharacterRole.Secondary]: { + includeRole: true, + includeNicknames: false, + includeShortDescription: true, + includeFullDescription: false, + includeRelations: true, + }, + [CharacterRole.Supporting]: { + includeRole: true, + includeNicknames: false, + includeShortDescription: false, + includeFullDescription: false, + includeRelations: false, + }, + [CharacterRole.Minor]: { + includeRole: true, + includeNicknames: false, + includeShortDescription: false, + includeFullDescription: false, + includeRelations: false, + }, + [CharacterRole.Cameo]: { + includeRole: true, + includeNicknames: false, + includeShortDescription: false, + includeFullDescription: false, + includeRelations: false, + }, + }; + export function formatCharactersMarkdown(state: AppState): string { const { currentStory } = state; if (!currentStory || !currentStory.characters?.length) { @@ -119,15 +184,40 @@ namespace Prompt { const lines: string[] = []; lines.push('## Characters\n'); - for (const character of currentStory.characters) { + // Sort characters by importance (protagonist first, cameo last) + const sortedCharacters = [...currentStory.characters].sort((a, b) => { + const importanceOrder = [ + CharacterRole.Protagonist, + CharacterRole.Main, + CharacterRole.Antagonist, + CharacterRole.Secondary, + CharacterRole.Supporting, + CharacterRole.Minor, + CharacterRole.Cameo, + ]; + return importanceOrder.indexOf(a.role) - importanceOrder.indexOf(b.role); + }); + + for (const character of sortedCharacters) { + const config = CHARACTER_ROLE_DETAIL[character.role]; lines.push(`### ${character.name}`); - const description = character.shortDescription || character.description; - if (description) { - lines.push(description); + if (config.includeRole) { + lines.push(`**Role:** ${character.role}`); } - if (character.relations?.length) { + if (config.includeNicknames && character.nicknames?.length) { + lines.push(`**Nicknames:** ${character.nicknames.join(', ')}`); + } + + const description = character.description || character.shortDescription; + if (config.includeFullDescription && description) { + lines.push(description); + } else if (config.includeShortDescription && character.shortDescription) { + lines.push(character.shortDescription); + } + + if (config.includeRelations && character.relations?.length) { lines.push('**Relations:**'); for (const relation of character.relations) { lines.push(`- ${relation.name}: ${relation.relation}`); diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 777ea0f..3b26ebb 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -1,6 +1,6 @@ import { formatErrorMessage } from "@common/errors"; import { Type, type Static, type TObject } from '@common/typebox'; -import { LocationScale, type AppState, type Character, type Location } from "../contexts/state"; +import { CharacterRole, LocationScale, type AppState, type Character, type Location } from "../contexts/state"; import type LLM from "./llm"; const SCALE_DESCRIPTION = Object.entries(LocationScale) @@ -16,6 +16,8 @@ const SCALE_NAMES = Object.fromEntries( .map(([key, value]) => [value, key]) ); +const VALID_ROLES = Object.values(CharacterRole); + export namespace Tools { interface Tool { description: string; @@ -36,6 +38,7 @@ export namespace Tools { return `Error: Character "${args.name.trim()}" not found`; } let result = `# Character: ${character.name}\n\n`; + result += `**Role:** ${character.role}\n\n`; if (character.nicknames && character.nicknames.length > 0) { result += `**Nicknames:** ${character.nicknames.join(', ')}\n\n`; } @@ -75,6 +78,9 @@ export namespace Tools { if (args.nicknames !== undefined) { definedUpdates.nicknames = args.nicknames; } + if (args.role !== undefined) { + definedUpdates.role = args.role; + } if (args.relations !== undefined) { return 'Error: set_character does not support updating relations. Use set_character_relation instead.'; } @@ -95,6 +101,9 @@ export namespace Tools { if (!args.shortDescription) { return 'Error: shortDescription is required when adding a new character'; } + if (args.role === undefined) { + return 'Error: role is required when adding a new character'; + } const existingCharacterNames = new Set(appState.currentStory.characters.map(c => c.name)); const invalidRelations: string[] = []; for (const rel of args.relations || []) { @@ -108,6 +117,7 @@ export namespace Tools { character: { id: crypto.randomUUID(), name: args.name.trim(), + role: args.role, nicknames: args.nicknames || [], shortDescription: args.shortDescription.trim(), description: args.description || '', @@ -130,6 +140,7 @@ export namespace Tools { parameters: Type.Object({ name: Type.String({ description: "The character's full name" }), shortDescription: Type.Optional(Type.String({ description: 'A brief description of the character (one line). Required on a new character.' })), + role: Type.Optional(Type.Enum(VALID_ROLES, { description: `The character role in the story. Required when adding a new character.` })), nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })), description: Type.Optional(Type.String({ description: 'Optional full character description' })), relations: Type.Optional(Type.Array(