1
0
Fork 0

Add character role

This commit is contained in:
Pabloader 2026-03-26 07:49:26 +00:00
parent e39de39e35
commit 50d6203a51
6 changed files with 173 additions and 28 deletions

View File

@ -4,8 +4,8 @@ const GlobalNumber = Number;
const GlobalArray = Array; const GlobalArray = Array;
export namespace Type { export namespace Type {
export function String(args: { description?: string, enum?: string[] } = {}) { export function String<S extends string = string>(args: { description?: string, enum?: S[] } = {}) {
const result: TString = { const result: TString<S> = {
type: 'string', type: 'string',
}; };
if (args.enum) { if (args.enum) {
@ -17,8 +17,8 @@ export namespace Type {
return result; return result;
} }
export function Number(args: { description?: string, enum?: number[] } = {}) { export function Number<N extends number = number>(args: { description?: string, enum?: N[] } = {}) {
const result: TNumber = { const result: TNumber<N> = {
type: 'number', type: 'number',
}; };
if (args.enum) { if (args.enum) {
@ -30,8 +30,8 @@ export namespace Type {
return result; return result;
} }
export function Integer(args: { description?: string, enum?: number[] } = {}) { export function Integer<N extends number = number>(args: { description?: string, enum?: N[] } = {}) {
const result: TNumber = { const result: TNumber<N> = {
type: 'integer', type: 'integer',
}; };
if (args.enum) { if (args.enum) {
@ -70,7 +70,7 @@ export namespace Type {
return result as unknown as TOptional<T>; return result as unknown as TOptional<T>;
} }
export function Object<T extends Record<string, TScheme> = Record<string, TScheme>>(properties: T, args: { description?: string } = {}) { export function Object<T extends TProperties = TProperties>(properties: T, args: { description?: string } = {}) {
const result: TObject<T> = { const result: TObject<T> = {
type: 'object', type: 'object',
properties, properties,
@ -83,13 +83,9 @@ export namespace Type {
return result; return result;
} }
type TEnumType<T extends (string | number) = (string | number)> = export function Enum<S extends string, T extends TString<S>>(items: S[], args?: { description?: string }): T;
T extends string ? TString<T> : export function Enum<N extends number, T extends TNumber<N>>(items: N[], args?: { description?: string }): T;
T extends number ? TNumber<T> : never; export function Enum(items: (number | string)[], args: { description?: string } = {}) {
export function Enum<T extends TString>(items: Static<T>[], args?: { description?: string }): T;
export function Enum<T extends TNumber>(items: Static<T>[], args?: { description?: string }): T;
export function Enum<T extends TEnumType = TEnumType>(items: Static<T>[], args: { description?: string } = {}) {
if (typeof items?.[0] === 'number') { if (typeof items?.[0] === 'number') {
return Number({ enum: items.map(item => +item!), ...args }); return Number({ enum: items.map(item => +item!), ...args });
} }
@ -195,7 +191,9 @@ export interface TArray<T extends TScheme = TScheme> {
items: T; items: T;
} }
export interface TObject<T extends Record<string, TScheme> = Record<string, TScheme>> { export type TProperties = Record<string, TScheme>;
export interface TObject<T extends TProperties = TProperties> {
type: 'object'; type: 'object';
description?: string; description?: string;
properties: T; properties: T;
@ -218,7 +216,7 @@ export interface TOptionalArray<T extends TScheme = TScheme> extends TArray<T> {
[optional]: true; [optional]: true;
} }
export interface TOptionalObject<T extends Record<string, TScheme> = Record<string, TScheme>> extends TObject<T> { export interface TOptionalObject<T extends TProperties = TProperties> extends TObject<T> {
[optional]: true; [optional]: true;
} }
@ -236,15 +234,15 @@ export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptiona
type Prettify<T> = { [K in keyof T]: T[K] } & {}; type Prettify<T> = { [K in keyof T]: T[K] } & {};
type RequiredKeys<T extends Record<string, TScheme>> = { type RequiredKeys<T extends TProperties> = {
[K in keyof T]: IsOptional<T[K]> extends true ? never : K [K in keyof T]: IsOptional<T[K]> extends true ? never : K
}[keyof T]; }[keyof T];
type OptionalKeys<T extends Record<string, TScheme>> = { type OptionalKeys<T extends TProperties> = {
[K in keyof T]: IsOptional<T[K]> extends true ? K : never [K in keyof T]: IsOptional<T[K]> extends true ? K : never
}[keyof T]; }[keyof T];
type StaticObject<T extends Record<string, TScheme>> = Prettify< type StaticObject<T extends TProperties> = Prettify<
{ [K in RequiredKeys<T>]: Static<T[K]> } & { [K in RequiredKeys<T>]: Static<T[K]> } &
{ [K in OptionalKeys<T>]?: Static<T[K]> } { [K in OptionalKeys<T>]?: Static<T[K]> }
>; >;

View File

@ -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 { .smallButton {
padding: 4px 10px; padding: 4px 10px;
background: var(--bg); background: var(--bg);

View File

@ -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 { useState } from "preact/hooks";
import styles from '../assets/character-editor.module.css'; import styles from '../assets/character-editor.module.css';
import LLM from "../utils/llm"; import LLM from "../utils/llm";
@ -22,6 +22,7 @@ export const CharacterEditor = () => {
character: { character: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: 'New Character', name: 'New Character',
role: CharacterRole.Main,
nicknames: [], nicknames: [],
shortDescription: '', shortDescription: '',
description: '', description: '',
@ -173,6 +174,23 @@ export const CharacterEditor = () => {
)} )}
</div> </div>
<div class={styles.field}>
<div class={styles.label}>Role</div>
<select
class={styles.select}
value={character.role}
onInput={(e) => handleEditCharacter(character.id, 'role', e.currentTarget.value as CharacterRole)}
>
{Object.entries(CharacterRole)
.filter(([, value]) => typeof value === 'string')
.map(([key, value]) => (
<option key={key} value={value}>
{key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()}
</option>
))}
</select>
</div>
<div class={styles.field}> <div class={styles.field}>
<div class={styles.label}>Description</div> <div class={styles.label}>Description</div>
<ContentEditable <ContentEditable

View File

@ -13,9 +13,20 @@ export type ChatMessage = LLM.ChatMessage & {
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters"; export type Tab = "story" | "lore" | "characters" | "locations" | "chapters";
export enum CharacterRole {
Protagonist = 'protagonist',
Antagonist = 'antagonist',
Main = 'main',
Secondary = 'secondary',
Supporting = 'supporting',
Minor = 'minor',
Cameo = 'cameo',
}
export interface Character { export interface Character {
id: string; id: string;
name: string; name: string;
role: CharacterRole;
nicknames: string[]; nicknames: string[];
shortDescription: string; shortDescription: string;
description: string; description: string;
@ -89,7 +100,7 @@ type Action =
| { type: 'SET_ENABLE_THINKING'; enable: boolean } | { type: 'SET_ENABLE_THINKING'; enable: boolean }
| { type: 'SET_BANNED_TOKENS'; tokens: string[] } | { type: 'SET_BANNED_TOKENS'; tokens: string[] }
| { type: 'ADD_CHARACTER'; storyId: string; character: Character } | { type: 'ADD_CHARACTER'; storyId: string; character: Character }
| { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial<Omit<Character, 'relations'>> } | { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> }
| { type: 'DELETE_CHARACTER'; storyId: string; characterId: string } | { type: 'DELETE_CHARACTER'; storyId: string; characterId: string }
| { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] } | { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] }
| { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> } | { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> }

View File

@ -1,6 +1,6 @@
import LLM from "./llm"; import LLM from "./llm";
import Chapters from "./chapters"; import Chapters from "./chapters";
import { type AppState, LocationScale } from "../contexts/state"; import { type AppState, CharacterRole, LocationScale } from "../contexts/state";
import { Tools } from "./tools"; import { Tools } from "./tools";
namespace Prompt { namespace Prompt {
@ -110,6 +110,71 @@ namespace Prompt {
return renderSlots(slots); 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, CharacterDetailConfig> = {
[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 { export function formatCharactersMarkdown(state: AppState): string {
const { currentStory } = state; const { currentStory } = state;
if (!currentStory || !currentStory.characters?.length) { if (!currentStory || !currentStory.characters?.length) {
@ -119,15 +184,40 @@ namespace Prompt {
const lines: string[] = []; const lines: string[] = [];
lines.push('## Characters\n'); 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}`); lines.push(`### ${character.name}`);
const description = character.shortDescription || character.description; if (config.includeRole) {
if (description) { lines.push(`**Role:** ${character.role}`);
lines.push(description);
} }
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:**'); lines.push('**Relations:**');
for (const relation of character.relations) { for (const relation of character.relations) {
lines.push(`- ${relation.name}: ${relation.relation}`); lines.push(`- ${relation.name}: ${relation.relation}`);

View File

@ -1,6 +1,6 @@
import { formatErrorMessage } from "@common/errors"; import { formatErrorMessage } from "@common/errors";
import { Type, type Static, type TObject } from '@common/typebox'; 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"; import type LLM from "./llm";
const SCALE_DESCRIPTION = Object.entries(LocationScale) const SCALE_DESCRIPTION = Object.entries(LocationScale)
@ -16,6 +16,8 @@ const SCALE_NAMES = Object.fromEntries(
.map(([key, value]) => [value, key]) .map(([key, value]) => [value, key])
); );
const VALID_ROLES = Object.values(CharacterRole);
export namespace Tools { export namespace Tools {
interface Tool<T extends TObject = TObject> { interface Tool<T extends TObject = TObject> {
description: string; description: string;
@ -36,6 +38,7 @@ export namespace Tools {
return `Error: Character "${args.name.trim()}" not found`; return `Error: Character "${args.name.trim()}" not found`;
} }
let result = `# Character: ${character.name}\n\n`; let result = `# Character: ${character.name}\n\n`;
result += `**Role:** ${character.role}\n\n`;
if (character.nicknames && character.nicknames.length > 0) { if (character.nicknames && character.nicknames.length > 0) {
result += `**Nicknames:** ${character.nicknames.join(', ')}\n\n`; result += `**Nicknames:** ${character.nicknames.join(', ')}\n\n`;
} }
@ -75,6 +78,9 @@ export namespace Tools {
if (args.nicknames !== undefined) { if (args.nicknames !== undefined) {
definedUpdates.nicknames = args.nicknames; definedUpdates.nicknames = args.nicknames;
} }
if (args.role !== undefined) {
definedUpdates.role = args.role;
}
if (args.relations !== undefined) { if (args.relations !== undefined) {
return 'Error: set_character does not support updating relations. Use set_character_relation instead.'; return 'Error: set_character does not support updating relations. Use set_character_relation instead.';
} }
@ -95,6 +101,9 @@ export namespace Tools {
if (!args.shortDescription) { if (!args.shortDescription) {
return 'Error: shortDescription is required when adding a new character'; 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 existingCharacterNames = new Set(appState.currentStory.characters.map(c => c.name));
const invalidRelations: string[] = []; const invalidRelations: string[] = [];
for (const rel of args.relations || []) { for (const rel of args.relations || []) {
@ -108,6 +117,7 @@ export namespace Tools {
character: { character: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: args.name.trim(), name: args.name.trim(),
role: args.role,
nicknames: args.nicknames || [], nicknames: args.nicknames || [],
shortDescription: args.shortDescription.trim(), shortDescription: args.shortDescription.trim(),
description: args.description || '', description: args.description || '',
@ -130,6 +140,7 @@ export namespace Tools {
parameters: Type.Object({ parameters: Type.Object({
name: Type.String({ description: "The character's full name" }), 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.' })), 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' })), nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })),
description: Type.Optional(Type.String({ description: 'Optional full character description' })), description: Type.Optional(Type.String({ description: 'Optional full character description' })),
relations: Type.Optional(Type.Array( relations: Type.Optional(Type.Array(