Locations editor
This commit is contained in:
parent
ca4f87db4b
commit
c8058f8663
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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: [],
|
||||||
};
|
};
|
||||||
|
|
@ -212,13 +237,13 @@ function reducer(state: IState, action: Action): IState {
|
||||||
stories: state.stories.map(s =>
|
stories: state.stories.map(s =>
|
||||||
s.id === action.storyId
|
s.id === action.storyId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
characters: s.characters.map(c =>
|
characters: s.characters.map(c =>
|
||||||
c.id === action.characterId
|
c.id === action.characterId
|
||||||
? { ...c, ...action.updates }
|
? { ...c, ...action.updates }
|
||||||
: c
|
: c
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: s
|
: s
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -239,13 +264,13 @@ function reducer(state: IState, action: Action): IState {
|
||||||
stories: state.stories.map(s =>
|
stories: state.stories.map(s =>
|
||||||
s.id === action.storyId
|
s.id === action.storyId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
characters: s.characters.map(c =>
|
characters: s.characters.map(c =>
|
||||||
c.id === action.characterId
|
c.id === action.characterId
|
||||||
? { ...c, relations: [...c.relations, action.relation] }
|
? { ...c, relations: [...c.relations, action.relation] }
|
||||||
: c
|
: c
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: s
|
: s
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -256,20 +281,20 @@ function reducer(state: IState, action: Action): IState {
|
||||||
stories: state.stories.map(s =>
|
stories: state.stories.map(s =>
|
||||||
s.id === action.storyId
|
s.id === action.storyId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
characters: s.characters.map(c =>
|
characters: s.characters.map(c =>
|
||||||
c.id === action.characterId
|
c.id === action.characterId
|
||||||
? {
|
? {
|
||||||
...c,
|
...c,
|
||||||
relations: c.relations.map(r =>
|
relations: c.relations.map(r =>
|
||||||
r.name === action.targetName
|
r.name === action.targetName
|
||||||
? { ...r, ...action.updates }
|
? { ...r, ...action.updates }
|
||||||
: r
|
: r
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: c
|
: c
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: s
|
: s
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -280,13 +305,50 @@ function reducer(state: IState, action: Action): IState {
|
||||||
stories: state.stories.map(s =>
|
stories: state.stories.map(s =>
|
||||||
s.id === action.storyId
|
s.id === action.storyId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
characters: s.characters.map(c =>
|
characters: s.characters.map(c =>
|
||||||
c.id === action.characterId
|
c.id === action.characterId
|
||||||
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
|
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
|
||||||
: c
|
: c
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
: s
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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
|
: s
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,17 +400,16 @@ 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);
|
|
||||||
}
|
|
||||||
if (typeof obj === 'object') {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(obj)
|
|
||||||
.map(([key, value]) => [key, parseArg(value)])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj)
|
||||||
|
.map(([key, value]) => [key, parseArg(value)])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
} catch { }
|
} catch { }
|
||||||
return arg;
|
return arg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue