1
0
Fork 0

Characters editor

This commit is contained in:
Pabloader 2026-03-23 15:47:05 +00:00
parent 6c8857478c
commit b2b84fa819
8 changed files with 934 additions and 11 deletions

View File

@ -0,0 +1,334 @@
.characterEditor {
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);
}
.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);
}
}
.deleteConfirm {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--bg-panel);
border: 1px solid var(--accent);
border-radius: var(--radius);
font-size: 13px;
& span {
font-weight: 500;
color: var(--accent);
}
}
.confirmButton,
.cancelButton {
padding: 4px 10px;
border: 1px solid transparent;
border-radius: var(--radius);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
}
.confirmButton {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
&:hover {
background: var(--bg);
color: var(--accent);
}
}
.cancelButton {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
&:hover {
background: var(--bg-hover);
color: var(--text);
}
}
.list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty {
color: var(--text-muted);
font-style: italic;
font-size: 14px;
}
.characterCard {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.cardHeader {
display: flex;
align-items: center;
gap: 12px;
}
.nameInput {
flex: 1;
padding: 8px 12px;
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);
}
}
.deleteButton {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 20px;
cursor: pointer;
transition: all var(--transition);
&:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
}
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field .label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.addInput {
display: flex;
gap: 6px;
}
.addInputField {
padding: 4px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--text);
font-family: inherit;
&:focus {
outline: none;
border-color: var(--accent);
}
&[placeholder="Add nickname..."] {
width: 120px;
}
&[placeholder="Relationship"] {
width: 100px;
}
&.relationInput {
flex: 1;
min-width: 0;
}
}
.relationInput {
flex: 1;
min-width: 0;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 13px;
color: var(--text);
font-family: inherit;
&:focus {
outline: none;
border-color: var(--accent);
}
}
.generateButton {
padding: 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--text);
cursor: pointer;
transition: all var(--transition);
&:hover {
border-color: var(--accent);
color: var(--accent);
}
}
.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;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--accent);
}
}
.smallButton {
padding: 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--text);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--accent);
color: var(--accent);
}
}
.nicknames {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
min-height: 40px;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--bg-active);
border-radius: 16px;
font-size: 13px;
color: var(--text);
}
.badgeRemove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
&:hover {
background: var(--danger);
color: var(--bg);
}
}
.relations {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.relationRow {
display: flex;
align-items: center;
gap: 8px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,301 @@
import { useAppState, type Character } from "../contexts/state";
import { useState } from "preact/hooks";
import styles from '../assets/character-editor.module.css';
export const CharacterEditor = () => {
const { currentStory, dispatch } = useAppState();
const [newNickname, setNewNickname] = useState<Record<string, string>>({});
const [newRelation, setNewRelation] = useState<Record<string, { name: string; relation: string }>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
if (!currentStory) {
return null;
}
const handleAddCharacter = () => {
dispatch({
type: 'ADD_CHARACTER',
storyId: currentStory.id,
character: {
id: crypto.randomUUID(),
name: 'New Character',
nicknames: [],
shortDescription: '',
description: '',
relations: [],
},
});
};
const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => {
dispatch({
type: 'EDIT_CHARACTER',
storyId: currentStory.id,
characterId,
updates: { [field]: value },
});
};
const handleDeleteCharacter = (characterId: string) => {
dispatch({
type: 'DELETE_CHARACTER',
storyId: currentStory.id,
characterId,
});
};
const handleAddRelation = (characterId: string) => {
const rel = newRelation[characterId] || { name: '', relation: '' };
if (!rel.name.trim() || !rel.relation.trim()) return;
dispatch({
type: 'ADD_CHARACTER_RELATION',
storyId: currentStory.id,
characterId,
relation: { name: rel.name.trim(), relation: rel.relation.trim() },
});
setNewRelation({ ...newRelation, [characterId]: { name: '', relation: '' } });
};
const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => {
dispatch({
type: 'EDIT_CHARACTER_RELATION',
storyId: currentStory.id,
characterId,
targetName,
updates: { [field]: value },
});
};
const handleDeleteRelation = (characterId: string, targetName: string) => {
dispatch({
type: 'DELETE_CHARACTER_RELATION',
storyId: currentStory.id,
characterId,
targetName,
});
};
const handleNicknameAdd = (characterId: string) => {
const nickname = (newNickname[characterId] || '').trim();
if (!nickname) return;
const character = currentStory.characters.find(c => c.id === characterId);
if (character) {
handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]);
setNewNickname({ ...newNickname, [characterId]: '' });
}
};
const handleNicknameDelete = (characterId: string, nickname: string) => {
const character = currentStory.characters.find(c => c.id === characterId);
if (character) {
handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname));
}
};
const handleNewRelationChange = (characterId: string, field: 'name' | 'relation', value: string) => {
const current = newRelation[characterId] || { name: '', relation: '' };
setNewRelation({ ...newRelation, [characterId]: { ...current, [field]: value } });
};
return (
<div class={styles.characterEditor}>
<div class={styles.header}>
<h2>Characters</h2>
<button class={styles.addButton} onClick={handleAddCharacter}>
+ Add Character
</button>
</div>
<div class={styles.list}>
{currentStory.characters.length === 0 && (
<p class={styles.empty}>No characters yet. Add your first character!</p>
)}
{currentStory.characters.map((character) => (
<div key={character.id} class={styles.characterCard}>
<div class={styles.cardHeader}>
<input
type="text"
class={styles.nameInput}
value={character.name}
onInput={(e) => handleEditCharacter(character.id, 'name', e.currentTarget.value)}
placeholder="Character name"
/>
{showDeleteConfirm === character.id ? (
<div class={styles.deleteConfirm}>
<span>Delete?</span>
<button
class={styles.confirmButton}
onClick={() => {
handleDeleteCharacter(character.id);
setShowDeleteConfirm(null);
}}
>
Yes
</button>
<button
class={styles.cancelButton}
onClick={() => setShowDeleteConfirm(null)}
>
No
</button>
</div>
) : (
<button
class={styles.deleteButton}
onClick={() => setShowDeleteConfirm(character.id)}
>
×
</button>
)}
</div>
<div class={styles.field}>
<div class={styles.label}>Description</div>
<textarea
class={styles.textarea}
value={character.description}
onInput={(e) => handleEditCharacter(character.id, 'description', e.currentTarget.value)}
placeholder="Full character description..."
rows={4}
/>
</div>
<div class={styles.field}>
<div class={styles.label}>
Short Description
<button class={styles.generateButton}>
Generate
</button>
</div>
<textarea
class={styles.textarea}
value={character.shortDescription}
onInput={(e) => handleEditCharacter(character.id, 'shortDescription', e.currentTarget.value)}
placeholder="Brief description (one line)..."
rows={1}
/>
</div>
<div class={styles.field}>
<div class={styles.label}>
Nicknames
<div class={styles.addInput}>
<input
type="text"
class={styles.addInputField}
value={newNickname[character.id] || ''}
onInput={(e) => setNewNickname({ ...newNickname, [character.id]: e.currentTarget.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleNicknameAdd(character.id);
}
}}
placeholder="Add nickname..."
/>
<button
class={styles.smallButton}
onClick={() => handleNicknameAdd(character.id)}
>
+
</button>
</div>
</div>
<div class={styles.nicknames}>
{character.nicknames.length === 0 && (
<span class={styles.empty}>No nicknames</span>
)}
{character.nicknames.map((nickname, idx) => (
<span key={idx} class={styles.badge}>
{nickname}
<button
class={styles.badgeRemove}
onClick={() => handleNicknameDelete(character.id, nickname)}
>
×
</button>
</span>
))}
</div>
</div>
<div class={styles.field}>
<div class={styles.label}>
Relations
<div class={styles.addInput}>
<select
class={styles.addInputField}
value={(newRelation[character.id]?.name) || ''}
onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)}
>
<option value="" disabled>Select character</option>
{currentStory.characters
.filter(c => c.id !== character.id)
.map(c => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
<input
type="text"
class={styles.addInputField}
value={(newRelation[character.id]?.relation) || ''}
onInput={(e) => handleNewRelationChange(character.id, 'relation', e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddRelation(character.id);
}
}}
placeholder="Relationship"
/>
<button
class={styles.smallButton}
onClick={() => handleAddRelation(character.id)}
>
+
</button>
</div>
</div>
<div class={styles.relations}>
{character.relations.length === 0 && (
<span class={styles.empty}>No relations</span>
)}
{character.relations.map((rel, idx) => (
<div key={idx} class={styles.relationRow}>
<select
class={styles.relationInput}
value={rel.name}
onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)}
>
{currentStory.characters
.filter(c => c.id !== character.id)
.map(c => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
<input
type="text"
class={styles.relationInput}
value={rel.relation}
onInput={(e) => handleEditRelation(character.id, rel.name, 'relation', e.currentTarget.value)}
placeholder="Relationship"
/>
<button
class={styles.smallButton}
onClick={() => handleDeleteRelation(character.id, rel.name)}
>
×
</button>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -56,7 +56,10 @@ export const ChatSidebar = () => {
behavior: 'smooth', behavior: 'smooth',
}); });
} }
}, [currentStory?.chatMessages.length]); }, [
currentStory?.chatMessages.length,
currentStory?.chatMessages.at(-1)?.content.length,
]);
useEffect(() => { useEffect(() => {
if (messagesRef.current) { if (messagesRef.current) {

View File

@ -3,6 +3,7 @@ import { useAppState, type Tab } from "../contexts/state";
import styles from '../assets/editor.module.css'; import styles from '../assets/editor.module.css';
import { highlight } from "../utils/highlight"; import { highlight } from "../utils/highlight";
import { useMemo } from "preact/hooks"; import { useMemo } from "preact/hooks";
import { CharacterEditor } from "./character-editor";
const TABS: { id: Tab; label: string }[] = [ const TABS: { id: Tab; label: string }[] = [
{ id: "story", label: "Story" }, { id: "story", label: "Story" },
@ -68,9 +69,7 @@ export const Editor = () => {
/> />
)} )}
{currentStory.currentTab === "characters" && ( {currentStory.currentTab === "characters" && (
<div class={styles.placeholder}> <CharacterEditor />
<p>Characters content placeholder</p>
</div>
)} )}
{currentStory.currentTab === "locations" && ( {currentStory.currentTab === "locations" && (
<div class={styles.placeholder}> <div class={styles.placeholder}>

View File

@ -12,11 +12,25 @@ export type ChatMessage = LLM.ChatMessage & {
export type Tab = "story" | "lore" | "characters" | "locations" export type Tab = "story" | "lore" | "characters" | "locations"
export interface Character {
id: string;
name: string;
nicknames: string[];
shortDescription: string;
description: string;
// Meaningful relationships with other characters
relations: ({
name: string; // character full name
relation: string; // daughter/friend/neighbour/etc
})[];
}
export interface Story { export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
lore: string; lore: string;
characters: Character[];
currentTab: Tab; currentTab: Tab;
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
} }
@ -47,7 +61,13 @@ type Action =
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null } | { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null } | { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
| { 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: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial<Omit<Character, '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]> }
| { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string };
// ─── Initial State ─────────────────────────────────────────────────────────── // ─── Initial State ───────────────────────────────────────────────────────────
@ -70,6 +90,7 @@ function reducer(state: IState, action: Action): IState {
title: action.title, title: action.title,
text: '', text: '',
lore: '', lore: '',
characters: [],
currentTab: 'story', currentTab: 'story',
chatMessages: [], chatMessages: [],
}; };
@ -175,6 +196,101 @@ function reducer(state: IState, action: Action): IState {
bannedTokens: action.tokens, bannedTokens: action.tokens,
}; };
} }
case 'ADD_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, characters: [...s.characters, action.character] }
: s
),
};
}
case 'EDIT_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, ...action.updates }
: c
),
}
: s
),
};
}
case 'DELETE_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, characters: s.characters.filter(c => c.id !== action.characterId) }
: s
),
};
}
case 'ADD_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: [...c.relations, action.relation] }
: c
),
}
: s
),
};
}
case 'EDIT_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? {
...c,
relations: c.relations.map(r =>
r.name === action.targetName
? { ...r, ...action.updates }
: r
),
}
: c
),
}
: s
),
};
}
case 'DELETE_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
: c
),
}
: s
),
};
}
} }
} }

View File

@ -1,5 +1,5 @@
import { formatError } from "@common/errors"; import { formatError } from "@common/errors";
import type { AppState } from "../contexts/state"; import type { AppState, Character } from "../contexts/state";
import type LLM from "./llm"; import type LLM from "./llm";
export namespace Tools { export namespace Tools {
@ -81,6 +81,157 @@ export namespace Tools {
}, },
required: ['text'], required: ['text'],
}, },
},
'add_character': {
handler: async (args, appState) => {
if (!args || typeof args !== 'object') {
return 'Error: Missing required arguments';
}
const { name, shortDescription, nicknames, description, relations } = args as {
name: string;
shortDescription: string;
nicknames?: string[];
description?: string;
relations?: { name: string; relation: string }[];
};
if (typeof name !== 'string' || !name.trim()) {
return 'Error: Argument "name" must be a non-empty string';
}
if (typeof shortDescription !== 'string' || !shortDescription.trim()) {
return 'Error: Argument "shortDescription" must be a non-empty string';
}
if (!appState.currentStory) {
return 'Error: No story selected';
}
// Filter out relations that reference non-existent characters
const existingCharacterNames = appState.currentStory.characters.map(c => c.name);
const invalidRelations: string[] = [];
const validRelations = (relations || []).filter(rel => {
if (!existingCharacterNames.includes(rel.name)) {
invalidRelations.push(`${rel.name} (${rel.relation})`);
return false;
}
return true;
});
appState.dispatch({
type: 'ADD_CHARACTER',
storyId: appState.currentStory.id,
character: {
id: crypto.randomUUID(),
name: name.trim(),
nicknames: nicknames || [],
shortDescription: shortDescription.trim(),
description: description || '',
relations: validRelations,
},
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
let message = `Character "${name.trim()}" added successfully`;
if (invalidRelations.length > 0) {
message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
}
return message;
},
description: 'Add a new character to the story',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The character\'s full name',
},
shortDescription: {
type: 'string',
description: 'A brief description of the character (one line)',
},
nicknames: {
type: 'array',
items: { type: 'string' },
description: 'Optional list of nicknames',
},
description: {
type: 'string',
description: 'Optional full character description',
},
relations: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Related character name' },
relation: { type: 'string', description: 'Relationship type' },
},
},
description: 'Optional list of relationships with other characters',
},
},
required: ['name', 'shortDescription'],
},
},
'edit_character': {
handler: async (args, appState) => {
if (!args || typeof args !== 'object') {
return 'Error: Missing required arguments';
}
const { name, shortDescription, description } = args as {
name: string;
shortDescription?: string;
description?: string;
};
if (typeof name !== 'string' || !name.trim()) {
return 'Error: Argument "name" must be a non-empty string';
}
if (!appState.currentStory) {
return 'Error: No story selected';
}
const character = appState.currentStory.characters.find(c => c.name === name.trim());
if (!character) {
return `Error: Character "${name.trim()}" not found`;
}
// Only include defined values to avoid setting fields to undefined
const definedUpdates: Partial<Character> = {};
if (shortDescription !== undefined) {
definedUpdates.shortDescription = shortDescription;
}
if (description !== undefined) {
definedUpdates.description = description;
}
appState.dispatch({
type: 'EDIT_CHARACTER',
storyId: appState.currentStory.id,
characterId: character.id,
updates: definedUpdates,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
return `Character "${name.trim()}" updated successfully`;
},
description: 'Edit an existing character\'s description',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The character\'s full name to identify which character to edit',
},
shortDescription: {
type: 'string',
description: 'Brief description of the character (one line)',
},
description: {
type: 'string',
description: 'Full character description',
},
},
required: ['name'],
},
} }
}; };
@ -97,14 +248,33 @@ export namespace Tools {
}); });
} }
export async function executeTool(appState: AppState, toolCall: LLM.ToolCall): Promise<string> { function parseArg(arg: unknown): unknown {
const { function: fn } = toolCall; if (typeof arg !== 'string') return arg;
let args = fn.arguments;
try { try {
if (typeof fn.arguments === 'string') { const obj = JSON.parse(arg);
args = JSON.parse(fn.arguments); if (obj) {
if (Array.isArray(obj)) {
return obj.map(parseArg);
}
if (typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj)
.map(([key, value]) => [key, parseArg(value)])
)
}
} }
} catch { } } catch { }
return arg;
}
export async function executeTool(appState: AppState, toolCall: LLM.ToolCall): Promise<string> {
const { function: fn } = toolCall;
const args = parseArg(fn.arguments);
if (!args || typeof args !== 'object') {
return 'Error: Arguments must be an object';
}
const handler = TOOLS[fn.name]?.handler; const handler = TOOLS[fn.name]?.handler;
if (!handler) { if (!handler) {