Add character role
This commit is contained in:
parent
e39de39e35
commit
50d6203a51
|
|
@ -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<S extends string = string>(args: { description?: string, enum?: S[] } = {}) {
|
||||
const result: TString<S> = {
|
||||
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<N extends number = number>(args: { description?: string, enum?: N[] } = {}) {
|
||||
const result: TNumber<N> = {
|
||||
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<N extends number = number>(args: { description?: string, enum?: N[] } = {}) {
|
||||
const result: TNumber<N> = {
|
||||
type: 'integer',
|
||||
};
|
||||
if (args.enum) {
|
||||
|
|
@ -70,7 +70,7 @@ export namespace Type {
|
|||
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> = {
|
||||
type: 'object',
|
||||
properties,
|
||||
|
|
@ -83,13 +83,9 @@ export namespace Type {
|
|||
return result;
|
||||
}
|
||||
|
||||
type TEnumType<T extends (string | number) = (string | number)> =
|
||||
T extends string ? TString<T> :
|
||||
T extends number ? TNumber<T> : never;
|
||||
|
||||
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 } = {}) {
|
||||
export function Enum<S extends string, T extends TString<S>>(items: S[], args?: { description?: string }): T;
|
||||
export function Enum<N extends number, T extends TNumber<N>>(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<T extends TScheme = TScheme> {
|
|||
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';
|
||||
description?: string;
|
||||
properties: T;
|
||||
|
|
@ -218,7 +216,7 @@ export interface TOptionalArray<T extends TScheme = TScheme> extends TArray<T> {
|
|||
[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;
|
||||
}
|
||||
|
||||
|
|
@ -236,15 +234,15 @@ export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptiona
|
|||
|
||||
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
|
||||
}[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
|
||||
}[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 OptionalKeys<T>]?: Static<T[K]> }
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
)}
|
||||
</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.label}>Description</div>
|
||||
<ContentEditable
|
||||
|
|
|
|||
|
|
@ -13,9 +13,20 @@ export type ChatMessage = LLM.ChatMessage & {
|
|||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
role: CharacterRole;
|
||||
nicknames: string[];
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
|
|
@ -89,7 +100,7 @@ type Action =
|
|||
| { type: 'SET_ENABLE_THINKING'; enable: boolean }
|
||||
| { type: 'SET_BANNED_TOKENS'; tokens: string[] }
|
||||
| { 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: '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]> }
|
||||
|
|
|
|||
|
|
@ -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, 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 {
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -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<T extends TObject = TObject> {
|
||||
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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue