1
0
Fork 0

Compare commits

..

3 Commits

Author SHA1 Message Date
Pabloader f3d916982f Refactor location scales 2026-03-26 08:44:50 +00:00
Pabloader 5359026466 Lore refactor to entries 2026-03-26 08:20:59 +00:00
Pabloader 50d6203a51 Add character role 2026-03-26 07:49:26 +00:00
11 changed files with 735 additions and 147 deletions

View File

@ -7,6 +7,7 @@ export const highlight = (message: string, keepMarkup = true): string => {
const headerRegex = /#{1,3} $/; const headerRegex = /#{1,3} $/;
const stack: string[] = []; const stack: string[] = [];
let inCodeBlock = false; let inCodeBlock = false;
let inMonospaced = false;
let inHeader = false; let inHeader = false;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@ -30,6 +31,17 @@ export const highlight = (message: string, keepMarkup = true): string => {
continue; continue;
} }
if (inMonospaced) {
if (token === '`' && isClose) {
inMonospaced = false;
stack.pop();
resultHTML += `${keepToken ? token : ''}</span>`;
} else {
resultHTML += token;
}
continue;
}
const headerMatch = token.match(headerRegex); const headerMatch = token.match(headerRegex);
if (headerMatch) { if (headerMatch) {
if (inHeader) resultHTML += '</span>'; if (inHeader) resultHTML += '</span>';
@ -68,6 +80,7 @@ export const highlight = (message: string, keepMarkup = true): string => {
resultHTML += `<span class="${styles.codeBlock}">${keepToken ? token : ''}`; resultHTML += `<span class="${styles.codeBlock}">${keepToken ? token : ''}`;
} else if (token === '`') { } else if (token === '`') {
stack.push(token); stack.push(token);
inMonospaced = true;
resultHTML += `<span class="${styles.inlineCode}">${keepToken ? token : ''}`; resultHTML += `<span class="${styles.inlineCode}">${keepToken ? token : ''}`;
} }
} }

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

@ -0,0 +1,200 @@
.loreEditor {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.header h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--text);
}
.addEntry {
display: flex;
gap: 8px;
align-items: center;
}
.titleInput {
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
color: var(--text);
font-family: inherit;
width: 250px;
&:focus {
outline: none;
border-color: var(--accent);
}
}
.addButton {
padding: 8px 16px;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
background: var(--accent-alt);
}
}
.list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty {
color: var(--text-muted);
font-style: italic;
font-size: 14px;
}
.entryCard {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.titleRow {
flex: 1;
min-width: 0;
}
.entryTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text);
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--accent);
}
}
.titleEditInput {
width: 100%;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 18px;
font-weight: 600;
color: var(--text);
font-family: inherit;
&:focus {
outline: none;
border-color: var(--accent);
}
}
.actions {
display: flex;
gap: 6px;
align-items: center;
}
.moveButton {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 14px;
cursor: pointer;
transition: all var(--transition);
&:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.deleteButton {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
transition: all var(--transition);
&:hover {
background: var(--danger);
border-color: var(--danger);
color: var(--bg);
}
}
.content {
min-height: 80px;
}
.textarea {
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;
resize: vertical;
min-height: 80px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--accent);
}
}

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

@ -7,6 +7,7 @@ import clsx from "clsx";
import { CharacterEditor } from "./character-editor"; import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor"; import { LocationEditor } from "./location-editor";
import { ChaptersEditor } from "./chapters-editor"; import { ChaptersEditor } from "./chapters-editor";
import { LoreEditor } from "./lore-editor";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
const TABS: { id: Tab; label: string }[] = [ const TABS: { id: Tab; label: string }[] = [
@ -29,15 +30,6 @@ export const Editor = () => {
}); });
}, [currentStory?.id]); }, [currentStory?.id]);
const handleLoreInput = useInputCallback((lore: string) => {
if (!currentStory) return;
dispatch({
type: 'EDIT_LORE',
id: currentStory.id,
lore,
});
}, [currentStory?.id]);
const handleTabChange = (tab: Tab) => { const handleTabChange = (tab: Tab) => {
if (!currentStory) return; if (!currentStory) return;
dispatch({ dispatch({
@ -48,7 +40,6 @@ export const Editor = () => {
}; };
const storyValue = useMemo(() => currentStory ? highlight(currentStory.text) : '', [currentStory?.text]); const storyValue = useMemo(() => currentStory ? highlight(currentStory.text) : '', [currentStory?.text]);
const loreValue = useMemo(() => currentStory ? highlight(currentStory.lore) : '', [currentStory?.lore]);
if (!currentStory) { if (!currentStory) {
return <div class={styles.editor} />; return <div class={styles.editor} />;
@ -69,12 +60,7 @@ export const Editor = () => {
/> />
)} )}
{currentStory.currentTab === "lore" && ( {currentStory.currentTab === "lore" && (
<ContentEditable <LoreEditor />
class={styles.editable}
value={loreValue}
onInput={handleLoreInput}
placeholder="Add lore, world-building details, and background information..."
/>
)} )}
{currentStory.currentTab === "characters" && ( {currentStory.currentTab === "characters" && (
<CharacterEditor /> <CharacterEditor />

View File

@ -4,13 +4,6 @@ import styles from '../assets/location-editor.module.css';
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
const SCALE_OPTIONS = Object.entries(LocationScale)
.filter(([, value]) => typeof value === 'number')
.map(([label, value]) => ({
value,
label,
}));
export const LocationEditor = () => { export const LocationEditor = () => {
const { currentStory, dispatch, connection, model } = useAppState(); const { currentStory, dispatch, connection, model } = useAppState();
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
@ -127,11 +120,11 @@ export const LocationEditor = () => {
<select <select
class={styles.select} class={styles.select}
value={location.scale} value={location.scale}
onInput={(e) => handleEditLocation(location.id, 'scale', Number(e.currentTarget.value) as LocationScale)} onInput={(e) => handleEditLocation(location.id, 'scale', e.currentTarget.value as LocationScale)}
> >
{SCALE_OPTIONS.map((option) => ( {Object.values(LocationScale).map((option) => (
<option key={option.value} value={option.value}> <option key={option} value={option}>
{option.label} {option.charAt(0).toUpperCase() + option.slice(1).toLowerCase()}
</option> </option>
))} ))}
</select> </select>

View File

@ -0,0 +1,171 @@
import { useAppState, type LoreEntry } from "../contexts/state";
import styles from '../assets/lore-editor.module.css';
import { ContentEditable } from "@common/components/ContentEditable";
import { useState } from "preact/hooks";
export const LoreEditor = () => {
const { currentStory, dispatch } = useAppState();
const [editingId, setEditingId] = useState<string | null>(null);
const [newTitle, setNewTitle] = useState('');
if (!currentStory) {
return null;
}
const handleAddEntry = () => {
if (!newTitle.trim()) return;
dispatch({
type: 'ADD_LORE_ENTRY',
storyId: currentStory.id,
entry: {
id: crypto.randomUUID(),
title: newTitle.trim(),
text: '',
},
});
setNewTitle('');
setEditingId(null);
};
const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => {
dispatch({
type: 'EDIT_LORE_ENTRY',
storyId: currentStory.id,
entryId,
updates: { [field]: value },
});
};
const handleDeleteEntry = (entryId: string) => {
dispatch({
type: 'DELETE_LORE_ENTRY',
storyId: currentStory.id,
entryId,
});
};
const handleMoveUp = (index: number) => {
if (index === 0) return;
const entryIds = currentStory.lore.map(e => e.id);
[entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]];
dispatch({
type: 'REORDER_LORE_ENTRIES',
storyId: currentStory.id,
entryIds,
});
};
const handleMoveDown = (index: number) => {
if (index === currentStory.lore.length - 1) return;
const entryIds = currentStory.lore.map(e => e.id);
[entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]];
dispatch({
type: 'REORDER_LORE_ENTRIES',
storyId: currentStory.id,
entryIds,
});
};
return (
<div class={styles.loreEditor}>
<div class={styles.header}>
<h2>Lore</h2>
<div class={styles.addEntry}>
<input
type="text"
class={styles.titleInput}
value={newTitle}
onInput={(e) => setNewTitle(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEntry();
}
}}
placeholder="New lore entry title..."
/>
<button class={styles.addButton} onClick={handleAddEntry}>
+ Add Entry
</button>
</div>
</div>
<div class={styles.list}>
{currentStory.lore.length === 0 && (
<p class={styles.empty}>No lore entries yet. Add your first entry!</p>
)}
{currentStory.lore.map((entry, index) => (
<div key={entry.id} class={styles.entryCard}>
<div class={styles.cardHeader}>
<div class={styles.titleRow}>
{editingId === entry.id ? (
<input
type="text"
class={styles.titleEditInput}
value={entry.title}
onInput={(e) => handleEditEntry(entry.id, 'title', e.currentTarget.value)}
onBlur={() => setEditingId(null)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setEditingId(null);
} else if (e.key === 'Escape') {
setEditingId(null);
}
}}
autoFocus
/>
) : (
<h3
class={styles.entryTitle}
onClick={() => setEditingId(entry.id)}
title="Click to edit"
>
{entry.title}
</h3>
)}
</div>
<div class={styles.actions}>
<button
class={styles.moveButton}
onClick={() => handleMoveUp(index)}
disabled={index === 0}
title="Move up"
>
</button>
<button
class={styles.moveButton}
onClick={() => handleMoveDown(index)}
disabled={index === currentStory.lore.length - 1}
title="Move down"
>
</button>
<button
class={styles.deleteButton}
onClick={() => handleDeleteEntry(entry.id)}
title="Delete"
>
×
</button>
</div>
</div>
<div class={styles.content}>
<ContentEditable
autoLines
class={styles.textarea}
value={entry.text}
onInput={(e) => handleEditEntry(entry.id, 'text', e.currentTarget.textContent || '')}
placeholder="Enter lore content..."
/>
</div>
</div>
))}
</div>
</div>
);
};

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;
@ -27,15 +38,15 @@ export interface Character {
} }
export enum LocationScale { export enum LocationScale {
Room, Room = 'room',
House, House = 'house',
Street, Street = 'street',
City, City = 'city',
Region, Region = 'region',
Country, Country = 'country',
Continent, Continent = 'continent',
World, World = 'world',
Universe, Universe = 'universe',
} }
export interface Location { export interface Location {
@ -46,11 +57,17 @@ export interface Location {
scale: LocationScale; scale: LocationScale;
} }
export interface LoreEntry {
id: string;
title: string;
text: string;
}
export interface Story { export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
lore: string; lore: LoreEntry[];
characters: Character[]; characters: Character[];
locations: Location[]; locations: Location[];
currentTab: Tab; currentTab: Tab;
@ -76,7 +93,10 @@ type Action =
| { type: 'CREATE_STORY'; title: string } | { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string } | { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string } | { type: 'EDIT_STORY'; id: string; text: string }
| { type: 'EDIT_LORE'; id: string; lore: string } | { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry }
| { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial<LoreEntry> }
| { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string }
| { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: string[] }
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab } | { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
| { type: 'DELETE_STORY'; id: string } | { type: 'DELETE_STORY'; id: string }
@ -89,7 +109,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]> }
@ -121,7 +141,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: action.title, title: action.title,
text: '', text: '',
lore: '', lore: [],
characters: [], characters: [],
locations: [], locations: [],
currentTab: 'story', currentTab: 'story',
@ -150,14 +170,62 @@ function reducer(state: IState, action: Action): IState {
), ),
}; };
} }
case 'EDIT_LORE': { case 'ADD_LORE_ENTRY': {
return { return {
...state, ...state,
stories: state.stories.map(s => stories: state.stories.map(s =>
s.id === action.id ? { ...s, lore: action.lore } : s s.id === action.storyId
? { ...s, lore: [...s.lore, action.entry] }
: s
), ),
}; };
} }
case 'EDIT_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
lore: s.lore.map(e =>
e.id === action.entryId
? { ...e, ...action.updates }
: e
),
}
: s
),
};
}
case 'DELETE_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, lore: s.lore.filter(e => e.id !== action.entryId) }
: s
),
};
}
case 'REORDER_LORE_ENTRIES': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const entryMap = new Map(s.lore.map(e => [e.id, e]));
const reordered = action.entryIds
.map(id => entryMap.get(id))
.filter((e): e is LoreEntry => e !== undefined);
// Add any entries that weren't in the new order (safety)
for (const entry of s.lore) {
if (!action.entryIds.includes(entry.id)) {
reordered.push(entry);
}
}
return { ...s, lore: reordered };
}),
};
}
case 'SET_SYSTEM_INSTRUCTION': { case 'SET_SYSTEM_INSTRUCTION': {
return { return {
...state, ...state,
@ -188,7 +256,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: `${original.title} (Copy)`, title: `${original.title} (Copy)`,
text: '', text: '',
lore: original.lore, lore: [...original.lore],
characters: original.characters, characters: original.characters,
locations: original.locations, locations: original.locations,
currentTab: 'story', currentTab: 'story',

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 } 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}`);
@ -140,6 +230,26 @@ namespace Prompt {
return lines.join('\n'); return lines.join('\n');
} }
export function formatLoreMarkdown(state: AppState): string {
const { currentStory } = state;
if (!currentStory?.lore?.length) {
return '';
}
const lines: string[] = [];
lines.push('## Lore\n');
for (const entry of currentStory.lore) {
lines.push(`### ${entry.title}`);
if (entry.text) {
lines.push(entry.text);
}
lines.push('');
}
return lines.join('\n');
}
export function formatLocationsMarkdown(state: AppState): string { export function formatLocationsMarkdown(state: AppState): string {
const { currentStory } = state; const { currentStory } = state;
if (!currentStory || !currentStory.locations?.length) { if (!currentStory || !currentStory.locations?.length) {
@ -157,7 +267,7 @@ namespace Prompt {
lines.push(description); lines.push(description);
} }
lines.push(`**Scale:** ${LocationScale[location.scale]}`); lines.push(`**Scale:** ${location.scale}`);
lines.push(''); lines.push('');
} }
@ -174,8 +284,9 @@ namespace Prompt {
parts.push(`# Story Title: ${currentStory.title}`); parts.push(`# Story Title: ${currentStory.title}`);
if (currentStory.lore) { const loreSection = formatLoreMarkdown(state);
parts.push(`## Lore`, currentStory.lore); if (loreSection) {
parts.push(loreSection);
} }
const charactersSection = formatCharactersMarkdown(state); const charactersSection = formatCharactersMarkdown(state);

View File

@ -1,20 +1,10 @@
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 VALID_SCALES = Object.values(LocationScale);
.filter(([, value]) => typeof value === 'number') const VALID_ROLES = Object.values(CharacterRole);
.map(([key, value]) => `${value}=${key}`)
.join(', ');
const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number');
const SCALE_NAMES = Object.fromEntries(
Object.entries(LocationScale)
.filter(([, value]) => typeof value === 'number')
.map(([key, value]) => [value, key])
);
export namespace Tools { export namespace Tools {
interface Tool<T extends TObject = TObject> { interface Tool<T extends TObject = TObject> {
@ -36,6 +26,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 +66,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 +89,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 +105,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 +128,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(
@ -210,7 +209,7 @@ export namespace Tools {
if (location.description) { if (location.description) {
result += `**Description:** ${location.description}\n\n`; result += `**Description:** ${location.description}\n\n`;
} }
result += `**Scale:** ${SCALE_NAMES[location.scale]}\n`; result += `**Scale:** ${location.scale}\n`;
return result.trim(); return result.trim();
}, },
description: 'Get full information about a location by name', description: 'Get full information about a location by name',
@ -281,91 +280,102 @@ export namespace Tools {
shortDescription: Type.Optional(Type.String({ description: 'A brief description of the location (one line). Required when adding a new location.' })), shortDescription: Type.Optional(Type.String({ description: 'A brief description of the location (one line). Required when adding a new location.' })),
description: Type.Optional(Type.String({ description: 'Optional full location description' })), description: Type.Optional(Type.String({ description: 'Optional full location description' })),
scale: Type.Optional(Type.Enum(VALID_SCALES, { scale: Type.Optional(Type.Enum(VALID_SCALES, {
description: `Location scale (enum): ${SCALE_DESCRIPTION}`, description: `Location scale (enum): ${VALID_SCALES.join(', ')}`,
})), })),
}), }),
}), }),
'set_lore_entry': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const existingEntry = appState.currentStory.lore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase());
if (existingEntry) {
appState.dispatch({
type: 'EDIT_LORE_ENTRY',
storyId: appState.currentStory.id,
entryId: existingEntry.id,
updates: { text: args.text },
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return `Lore entry "${existingEntry.title}" updated successfully`;
} else {
appState.dispatch({
type: 'ADD_LORE_ENTRY',
storyId: appState.currentStory.id,
entry: {
id: crypto.randomUUID(),
title: args.title.trim(),
text: args.text,
},
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return `Lore entry "${args.title.trim()}" added successfully`;
}
},
description: 'Add or edit a lore entry. If entry with matching title exists, updates it; otherwise creates new one.',
parameters: Type.Object({
title: Type.String({ description: "The lore entry's title" }),
text: Type.String({ description: 'The lore entry content.' }),
}),
}),
'edit_text': tool({ 'edit_text': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
const target = args.target ?? 'story';
// Append mode: when old_text is not provided, append new_text to the target // Append mode: when old_text is not provided, append new_text to the story
if (args.old_text == null) { if (args.old_text == null) {
if (target === 'lore') {
appState.dispatch({
type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore + '\n' + args.new_text,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
} else {
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text + '\n' + args.new_text,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'story'
});
}
return `Text appended to ${target} successfully`;
}
// Replace mode: find and replace old_text with new_text
const source = target === 'lore'
? appState.currentStory.lore
: appState.currentStory.text;
const occurrences = source.split(args.old_text).length - 1;
if (occurrences === 0) {
return `Error: old_text not found in ${target}`;
}
if (occurrences > 1 && !args.replace_all) {
return `Error: old_text appears multiple times in ${target}`;
}
if (target === 'lore') {
appState.dispatch({
type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore.replaceAll(args.old_text, args.new_text),
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
} else {
appState.dispatch({ appState.dispatch({
type: 'EDIT_STORY', type: 'EDIT_STORY',
id: appState.currentStory.id, id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text), text: appState.currentStory.text + '\n' + args.new_text,
}); });
appState.dispatch({ appState.dispatch({
type: 'SET_CURRENT_TAB', type: 'SET_CURRENT_TAB',
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'story' tab: 'story'
}); });
return 'Text appended to story successfully';
} }
return `${target === 'lore' ? 'Lore' : 'Story'} edited successfully`;
// Replace mode: find and replace old_text with new_text in story
const source = appState.currentStory.text;
const occurrences = source.split(args.old_text).length - 1;
if (occurrences === 0) {
return 'Error: old_text not found in story';
}
if (occurrences > 1 && !args.replace_all) {
return 'Error: old_text appears multiple times in story';
}
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'story'
});
return 'Story edited successfully';
}, },
description: "Replace text in the story or lore. When old_text is omitted, appends new_text to the target's end. Case-sensitive.", description: "Replace text in the story. When old_text is omitted, appends new_text to the story's end. Case-sensitive.",
parameters: Type.Object({ parameters: Type.Object({
new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }), new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }),
old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })), old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })),
replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })), replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })),
target: Type.Optional(Type.Enum(['lore', 'story'],
{ description: 'Target to edit (story or lore, default: story)' },
)),
}), }),
}), }),
'grep': tool({ 'grep': tool({
@ -376,7 +386,10 @@ export namespace Tools {
const sources: { name: string; content: string }[] = [ const sources: { name: string; content: string }[] = [
{ name: 'story', content: appState.currentStory.text }, { name: 'story', content: appState.currentStory.text },
{ name: 'lore', content: appState.currentStory.lore }, ...appState.currentStory.lore.flatMap(e => [
{ name: `lore:${e.title}`, content: e.title },
{ name: `lore:${e.title}`, content: e.text },
]),
...appState.currentStory.characters.flatMap(c => [ ...appState.currentStory.characters.flatMap(c => [
{ name: `character:${c.name}`, content: c.name }, { name: `character:${c.name}`, content: c.name },
{ name: `character:${c.name}`, content: c.shortDescription }, { name: `character:${c.name}`, content: c.shortDescription },