Characters editor
This commit is contained in:
parent
6c8857478c
commit
b2b84fa819
|
|
@ -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 |
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue