1
0
Fork 0

Locations editor

This commit is contained in:
Pabloader 2026-03-23 21:00:42 +00:00
parent ca4f87db4b
commit c8058f8663
7 changed files with 643 additions and 56 deletions

View File

@ -0,0 +1,225 @@
.locationEditor {
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;
}
.locationCard {
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%;
}
.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);
}
}
.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);
}
}

View File

@ -121,6 +121,7 @@ export const CharacterEditor = () => {
class={styles.nameInput} class={styles.nameInput}
value={character.name} value={character.name}
onInput={(e) => handleEditCharacter(character.id, 'name', e.currentTarget.value)} onInput={(e) => handleEditCharacter(character.id, 'name', e.currentTarget.value)}
onFocus={(e) => e.currentTarget.select()}
placeholder="Character name" placeholder="Character name"
/> />
{showDeleteConfirm === character.id ? ( {showDeleteConfirm === character.id ? (

View File

@ -50,6 +50,9 @@ export const ChatSidebar = () => {
const messagesRef = useRef<HTMLDivElement>(null); const messagesRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController>(new AbortController()); const abortControllerRef = useRef<AbortController>(new AbortController());
const appStateRef = useRef(appState);
appStateRef.current = appState;
useEffect(() => { useEffect(() => {
if (messagesRef.current) { if (messagesRef.current) {
messagesRef.current.scrollTo({ messagesRef.current.scrollTo({
@ -88,7 +91,7 @@ export const ChatSidebar = () => {
messages.push({ role: 'user', content: input.trim() }); messages.push({ role: 'user', content: input.trim() });
} }
const chatRequest = Prompt.compilePrompt(appState, messages); const chatRequest = Prompt.compilePrompt(appStateRef.current, messages);
const countRequest: LLM.CountTokensRequest = { const countRequest: LLM.CountTokensRequest = {
model: model.id, model: model.id,
input: chatRequest?.messages ?? [], input: chatRequest?.messages ?? [],
@ -134,7 +137,7 @@ export const ChatSidebar = () => {
}, },
}); });
const request = Prompt.compilePrompt(appState, newMessages); const request = Prompt.compilePrompt(appStateRef.current, newMessages);
if (!request) { if (!request) {
setError('Failed to compile prompt'); setError('Failed to compile prompt');
@ -195,7 +198,7 @@ export const ChatSidebar = () => {
if (tool_calls) { if (tool_calls) {
const toolMessages: ChatMessage[] = []; const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) { for (const tool of tool_calls) {
const content = await Tools.executeTool(appState, tool); const content = await Tools.executeTool(appStateRef.current, tool);
const message: ChatMessage = { const message: ChatMessage = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: 'tool', role: 'tool',
@ -219,7 +222,7 @@ export const ChatSidebar = () => {
setError(errorMessage); setError(errorMessage);
} }
}, [appState, currentStory, connection, model]); }, [currentStory, connection, model]);
const handleSendMessage = useCallback(async () => { const handleSendMessage = useCallback(async () => {
if (!currentStory || !input.trim() || !connection || !model || isLoading) return; if (!currentStory || !input.trim() || !connection || !model || isLoading) return;
@ -240,7 +243,7 @@ export const ChatSidebar = () => {
setIsLoading(false); setIsLoading(false);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
} }
}, [currentStory, input, connection, model, isLoading]); }, [currentStory, input, connection, model, isLoading, sendMessage]);
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {

View File

@ -4,6 +4,7 @@ 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"; import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor";
const TABS: { id: Tab; label: string }[] = [ const TABS: { id: Tab; label: string }[] = [
{ id: "story", label: "Story" }, { id: "story", label: "Story" },
@ -72,9 +73,7 @@ export const Editor = () => {
<CharacterEditor /> <CharacterEditor />
)} )}
{currentStory.currentTab === "locations" && ( {currentStory.currentTab === "locations" && (
<div class={styles.placeholder}> <LocationEditor />
<p>Locations content placeholder</p>
</div>
)} )}
</div> </div>
<div class={styles.tabs}> <div class={styles.tabs}>

View File

@ -0,0 +1,151 @@
import { useAppState, type Location, LocationScale } from "../contexts/state";
import { useState } from "preact/hooks";
import styles from '../assets/location-editor.module.css';
const SCALE_OPTIONS = Object.entries(LocationScale)
.filter(([, value]) => typeof value === 'number')
.map(([label, value]) => ({
value,
label,
}));
export const LocationEditor = () => {
const { currentStory, dispatch } = useAppState();
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
if (!currentStory) {
return null;
}
const handleAddLocation = () => {
dispatch({
type: 'ADD_LOCATION',
storyId: currentStory.id,
location: {
id: crypto.randomUUID(),
name: 'New Location',
shortDescription: '',
description: '',
scale: LocationScale.Room,
},
});
};
const handleEditLocation = (locationId: string, field: keyof Location, value: any) => {
dispatch({
type: 'EDIT_LOCATION',
storyId: currentStory.id,
locationId,
updates: { [field]: value },
});
};
const handleDeleteLocation = (locationId: string) => {
dispatch({
type: 'DELETE_LOCATION',
storyId: currentStory.id,
locationId,
});
};
return (
<div class={styles.locationEditor}>
<div class={styles.header}>
<h2>Locations</h2>
<button class={styles.addButton} onClick={handleAddLocation}>
+ Add Location
</button>
</div>
<div class={styles.list}>
{currentStory.locations.length === 0 && (
<p class={styles.empty}>No locations yet. Add your first location!</p>
)}
{currentStory.locations.map((location) => (
<div key={location.id} class={styles.locationCard}>
<div class={styles.cardHeader}>
<input
type="text"
class={styles.nameInput}
value={location.name}
onInput={(e) => handleEditLocation(location.id, 'name', e.currentTarget.value)}
onFocus={(e) => e.currentTarget.select()}
placeholder="Location name"
/>
{showDeleteConfirm === location.id ? (
<div class={styles.deleteConfirm}>
<span>Delete?</span>
<button
class={styles.confirmButton}
onClick={() => {
handleDeleteLocation(location.id);
setShowDeleteConfirm(null);
}}
>
Yes
</button>
<button
class={styles.cancelButton}
onClick={() => setShowDeleteConfirm(null)}
>
No
</button>
</div>
) : (
<button
class={styles.deleteButton}
onClick={() => setShowDeleteConfirm(location.id)}
>
×
</button>
)}
</div>
<div class={styles.field}>
<div class={styles.label}>Scale</div>
<select
class={styles.select}
value={location.scale}
onInput={(e) => handleEditLocation(location.id, 'scale', Number(e.currentTarget.value) as LocationScale)}
>
{SCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div class={styles.field}>
<div class={styles.label}>Description</div>
<textarea
class={styles.textarea}
value={location.description}
onInput={(e) => handleEditLocation(location.id, 'description', e.currentTarget.value)}
placeholder="Full location 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={location.shortDescription}
onInput={(e) => handleEditLocation(location.id, 'shortDescription', e.currentTarget.value)}
placeholder="Brief description (one line)..."
rows={1}
/>
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -10,7 +10,7 @@ export type ChatMessage = LLM.ChatMessage & {
id: string; id: string;
} }
export type Tab = "story" | "lore" | "characters" | "locations" export type Tab = "story" | "lore" | "characters" | "locations";
export interface Character { export interface Character {
id: string; id: string;
@ -25,12 +25,33 @@ export interface Character {
})[]; })[];
} }
export enum LocationScale {
Room,
House,
Street,
City,
Region,
Country,
Continent,
World,
Universe,
}
export interface Location {
id: string;
name: string;
shortDescription: string;
description: string;
scale: LocationScale;
}
export interface Story { export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
lore: string; lore: string;
characters: Character[]; characters: Character[];
locations: Location[];
currentTab: Tab; currentTab: Tab;
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
} }
@ -67,7 +88,10 @@ type Action =
| { 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]> }
| { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string }; | { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string }
| { type: 'ADD_LOCATION'; storyId: string; location: Location }
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> }
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string };
// ─── Initial State ─────────────────────────────────────────────────────────── // ─── Initial State ───────────────────────────────────────────────────────────
@ -91,6 +115,7 @@ function reducer(state: IState, action: Action): IState {
text: '', text: '',
lore: '', lore: '',
characters: [], characters: [],
locations: [],
currentTab: 'story', currentTab: 'story',
chatMessages: [], chatMessages: [],
}; };
@ -291,6 +316,43 @@ function reducer(state: IState, action: Action): IState {
), ),
}; };
} }
case 'ADD_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, locations: [...s.locations, action.location] }
: s
),
};
}
case 'EDIT_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
locations: s.locations.map(l =>
l.id === action.locationId
? { ...l, ...action.updates }
: l
),
}
: s
),
};
}
case 'DELETE_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, locations: s.locations.filter(l => l.id !== action.locationId) }
: s
),
};
}
} }
} }

View File

@ -1,7 +1,14 @@
import { formatError } from "@common/errors"; import { formatError } from "@common/errors";
import type { AppState, Character } from "../contexts/state"; import { 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)
.filter(([, value]) => typeof value === 'number')
.map(([key, value]) => `${value}=${key}`)
.join(', ');
const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number');
export namespace Tools { export namespace Tools {
interface Tool { interface Tool {
description: string; description: string;
@ -232,6 +239,146 @@ export namespace Tools {
}, },
required: ['name'], required: ['name'],
}, },
},
'add_location': {
handler: async (args, appState) => {
if (!args || typeof args !== 'object') {
return 'Error: Missing required arguments';
}
const { name, shortDescription, description, scale } = args as {
name: string;
shortDescription: string;
description?: string;
scale: number;
};
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 (typeof scale !== 'number') {
return 'Error: Argument "scale" must be a number';
}
if (!VALID_SCALES.includes(scale)) {
return `Error: Argument "scale" must be a valid LocationScale value (${VALID_SCALES.join(', ')})`;
}
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'ADD_LOCATION',
storyId: appState.currentStory.id,
location: {
id: crypto.randomUUID(),
name: name.trim(),
shortDescription: shortDescription.trim(),
description: description || '',
scale: scale as LocationScale,
},
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${name.trim()}" added successfully`;
},
description: 'Add a new location to the story',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The location\'s full name',
},
shortDescription: {
type: 'string',
description: 'A brief description of the location (one line)',
},
description: {
type: 'string',
description: 'Optional full location description',
},
scale: {
type: 'number',
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
enum: VALID_SCALES
},
},
required: ['name', 'shortDescription', 'scale'],
},
},
'edit_location': {
handler: async (args, appState) => {
if (!args || typeof args !== 'object') {
return 'Error: Missing required arguments';
}
const { name, shortDescription, description, scale } = args as {
name: string;
shortDescription?: string;
description?: string;
scale?: number;
};
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 location = appState.currentStory.locations.find(l => l.name === name.trim());
if (!location) {
return `Error: Location "${name.trim()}" not found`;
}
const definedUpdates: Partial<Location> = {};
if (shortDescription !== undefined) {
definedUpdates.shortDescription = shortDescription;
}
if (description !== undefined) {
definedUpdates.description = description;
}
if (scale !== undefined) {
if (!VALID_SCALES.includes(scale)) {
return `Error: Argument "scale" must be a valid LocationScale value (${VALID_SCALES.join(', ')})`;
}
definedUpdates.scale = scale as LocationScale;
}
appState.dispatch({
type: 'EDIT_LOCATION',
storyId: appState.currentStory.id,
locationId: location.id,
updates: definedUpdates,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${name.trim()}" updated successfully`;
},
description: 'Edit an existing location\'s description',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The location\'s full name to identify which location to edit',
},
shortDescription: {
type: 'string',
description: 'Brief description of the location (one line)',
},
description: {
type: 'string',
description: 'Full location description',
},
scale: {
type: 'number',
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
},
},
required: ['name'],
},
} }
}; };
@ -253,7 +400,6 @@ export namespace Tools {
try { try {
const obj = JSON.parse(arg); const obj = JSON.parse(arg);
if (obj) {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(parseArg); return obj.map(parseArg);
} }
@ -263,7 +409,7 @@ export namespace Tools {
.map(([key, value]) => [key, parseArg(value)]) .map(([key, value]) => [key, parseArg(value)])
) )
} }
} return obj;
} catch { } } catch { }
return arg; return arg;
} }