Compare commits
No commits in common. "a605c9589057f66ac4daceabb9e71ac6040530b3" and "bbe5716e40945512f99fd2c73c661cb495a070d0" have entirely different histories.
a605c95890
...
bbe5716e40
|
|
@ -1,10 +0,0 @@
|
||||||
.autoLines {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root:empty::before {
|
|
||||||
content: attr(data-placeholder);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import { useEffect, useRef } from "preact/hooks";
|
import { useEffect, useRef } from "preact/hooks";
|
||||||
import type { JSX } from "preact";
|
import type { JSX } from "preact";
|
||||||
import clsx from "clsx";
|
|
||||||
import styles from "../assets/content-editable.module.css";
|
|
||||||
|
|
||||||
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
||||||
value: string;
|
value: string;
|
||||||
placeholder?: string;
|
|
||||||
autoLines?: boolean;
|
|
||||||
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -51,12 +47,7 @@ function setCaretOffset(el: HTMLElement, offset: number) {
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeToContent(el: HTMLElement) {
|
export const ContentEditable = ({ value, onInput, ...props }: Props) => {
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = el.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContentEditable = ({ value, placeholder, autoLines, onInput, class: externalClass, ...props }: Props) => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -66,11 +57,11 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class:
|
||||||
const offset = document.activeElement === el ? getCaretOffset(el) : null;
|
const offset = document.activeElement === el ? getCaretOffset(el) : null;
|
||||||
el.innerHTML = value;
|
el.innerHTML = value;
|
||||||
if (offset !== null) setCaretOffset(el, offset);
|
if (offset !== null) setCaretOffset(el, offset);
|
||||||
if (autoLines) resizeToContent(el);
|
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
if (e.key !== 'Enter') return;
|
if (e.key !== 'Enter') return;
|
||||||
|
const prevTextContent = (e.target as HTMLDivElement).textContent;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
|
|
@ -84,25 +75,31 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class:
|
||||||
range.setStartAfter(newline);
|
range.setStartAfter(newline);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
|
|
||||||
|
const nextTextContent = (e.target as HTMLDivElement).textContent;
|
||||||
|
|
||||||
|
// A trailing \n needs a following character to render in pre-line.
|
||||||
|
// If nothing follows the inserted newline, add a sentinel \n so the
|
||||||
|
// new line is visible, then place the caret before it.
|
||||||
|
const atEnd = nextTextContent.startsWith(prevTextContent) && nextTextContent !== prevTextContent && nextTextContent.length === prevTextContent.length + 1 && nextTextContent.at(-1) === '\n';
|
||||||
|
if (atEnd) {
|
||||||
|
const sentinel = document.createTextNode('\n');
|
||||||
|
range.insertNode(sentinel);
|
||||||
|
range.setStartBefore(sentinel);
|
||||||
|
range.collapse(true);
|
||||||
|
}
|
||||||
|
|
||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
|
|
||||||
ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
|
|
||||||
if (autoLines && ref.current) resizeToContent(ref.current);
|
|
||||||
onInput?.(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
contentEditable
|
contentEditable
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onInput={handleInput}
|
onInput={onInput}
|
||||||
data-placeholder={placeholder}
|
|
||||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
.chaptersEditor {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterCard {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterTitle {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'Georgia', serif;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunkHeader {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunkPreview {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
border-left: 2px solid var(--border);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summaryEditable {
|
|
||||||
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;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 80px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -133,35 +133,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tokenCounter {
|
.tokenCounter {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summarizeButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 4px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggleContainer {
|
.toggleContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -66,11 +65,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.tabRight {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { useMemo } from "preact/hooks";
|
|
||||||
import { useAppState } from "../contexts/state";
|
|
||||||
import Chapters from "../utils/chapters";
|
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
|
||||||
import { highlight } from "@common/highlight";
|
|
||||||
import styles from "../assets/chapters-editor.module.css";
|
|
||||||
|
|
||||||
export const ChaptersEditor = () => {
|
|
||||||
const { currentStory, dispatch } = useAppState();
|
|
||||||
|
|
||||||
if (!currentStory) return null;
|
|
||||||
|
|
||||||
const parsed = useMemo(
|
|
||||||
() => Chapters.parseText(currentStory.text),
|
|
||||||
[currentStory.text]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
return (
|
|
||||||
<div class={styles.chaptersEditor}>
|
|
||||||
<p class={styles.empty}>No chapters yet. Use <code># Chapter Title</code> headers in your story to create chapters.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.chaptersEditor}>
|
|
||||||
{parsed.map((parsedChapter) => {
|
|
||||||
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
|
|
||||||
const cachedChapter = (currentStory.chapters ?? []).find(c => c.header === parsedChapter.header)
|
|
||||||
?? Chapters.emptyChapter(parsedChapter.header);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.chapterCard} key={parsedChapter.header || '__preamble__'}>
|
|
||||||
<div class={styles.chapterTitle}>
|
|
||||||
{parsedChapter.header ? parsedChapter.header.replace(/^# /, '') : '(Preamble)'}
|
|
||||||
</div>
|
|
||||||
{chunks.map((body, i) => {
|
|
||||||
const { hash, summary } = Chapters.lookupSummary(cachedChapter, body);
|
|
||||||
return (
|
|
||||||
<div class={styles.chunk} key={i}>
|
|
||||||
{chunks.length > 1 && (
|
|
||||||
<div class={styles.chunkHeader}>Part {i + 1}</div>
|
|
||||||
)}
|
|
||||||
<div class={styles.chunkPreview}>{body}</div>
|
|
||||||
<ContentEditable
|
|
||||||
class={styles.summaryEditable}
|
|
||||||
value={highlight(summary ?? '')}
|
|
||||||
placeholder="Not summarized yet..."
|
|
||||||
onInput={(e) => dispatch({
|
|
||||||
type: 'STORE_CHAPTER_SUMMARY',
|
|
||||||
storyId: currentStory.id,
|
|
||||||
header: parsedChapter.header,
|
|
||||||
hash,
|
|
||||||
summary: (e.target as HTMLDivElement).textContent || '',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -2,13 +2,11 @@ import { useInputState } from "@common/hooks/useInputState";
|
||||||
import { highlight } from "@common/highlight";
|
import { highlight } from "@common/highlight";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
import { useAppState, type ChatMessage } from "../contexts/state";
|
import { useAppState, type ChatMessage } from "../contexts/state";
|
||||||
import { useChapterSummarization } from "../utils/useChapterSummarization";
|
|
||||||
import styles from '../assets/chat-sidebar.module.css';
|
import styles from '../assets/chat-sidebar.module.css';
|
||||||
import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks";
|
import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks";
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
import Prompt from "../utils/prompt";
|
import Prompt from "../utils/prompt";
|
||||||
import { Tools } from "../utils/tools";
|
import { Tools } from "../utils/tools";
|
||||||
import { Sparkles } from "lucide-preact";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// ─── Role Header ──────────────────────────────────────────────────────────────
|
// ─── Role Header ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -43,7 +41,6 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
|
||||||
export const ChatSidebar = () => {
|
export const ChatSidebar = () => {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const { currentStory, dispatch, connection, model, enableThinking } = appState;
|
const { currentStory, dispatch, connection, model, enableThinking } = appState;
|
||||||
const { summarizeAll, isSummarizing } = useChapterSummarization();
|
|
||||||
const [input, setInput] = useInputState('');
|
const [input, setInput] = useInputState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isCollapsed, setCollapsed] = useState(false);
|
const [isCollapsed, setCollapsed] = useState(false);
|
||||||
|
|
@ -339,16 +336,11 @@ export const ChatSidebar = () => {
|
||||||
/>
|
/>
|
||||||
<span>Enable thinking</span>
|
<span>Enable thinking</span>
|
||||||
</label>
|
</label>
|
||||||
<div class={styles.tokenCounter}>
|
{tokenCount && (
|
||||||
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
<div class={styles.tokenCounter}>
|
||||||
<button
|
{tokenCount.taken} / {tokenCount.total} tokens
|
||||||
class={styles.summarizeButton}
|
</div>
|
||||||
onClick={summarizeAll}
|
)}
|
||||||
disabled={isSummarizing || !currentStory || !connection || !model}
|
|
||||||
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
|
|
||||||
<Sparkles size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
class={styles.input}
|
class={styles.input}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,11 @@ import { highlight } from "@common/highlight";
|
||||||
import { useAppState, type Tab } from "../contexts/state";
|
import { useAppState, type Tab } from "../contexts/state";
|
||||||
import styles from '../assets/editor.module.css';
|
import styles from '../assets/editor.module.css';
|
||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
import clsx from "clsx";
|
|
||||||
import { CharacterEditor } from "./character-editor";
|
import { CharacterEditor } from "./character-editor";
|
||||||
import { LocationEditor } from "./location-editor";
|
import { LocationEditor } from "./location-editor";
|
||||||
import { ChaptersEditor } from "./chapters-editor";
|
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string }[] = [
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
{ id: "story", label: "Story" },
|
{ id: "story", label: "Story" },
|
||||||
{ id: "chapters", label: "Chapters" },
|
|
||||||
{ id: "lore", label: "Lore" },
|
{ id: "lore", label: "Lore" },
|
||||||
{ id: "characters", label: "Characters" },
|
{ id: "characters", label: "Characters" },
|
||||||
{ id: "locations", label: "Locations" },
|
{ id: "locations", label: "Locations" },
|
||||||
|
|
@ -54,9 +51,7 @@ export const Editor = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.editor}>
|
<div class={styles.editor}>
|
||||||
<div class={styles.title}>
|
<div class={styles.title}>{currentStory.title}</div>
|
||||||
{currentStory.title}
|
|
||||||
</div>
|
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
{currentStory.currentTab === "story" && (
|
{currentStory.currentTab === "story" && (
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
|
|
@ -80,15 +75,12 @@ export const Editor = () => {
|
||||||
{currentStory.currentTab === "locations" && (
|
{currentStory.currentTab === "locations" && (
|
||||||
<LocationEditor />
|
<LocationEditor />
|
||||||
)}
|
)}
|
||||||
{currentStory.currentTab === "chapters" && (
|
|
||||||
<ChaptersEditor />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.tabs}>
|
<div class={styles.tabs}>
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
class={clsx(styles.tab, currentStory.currentTab === tab.id && styles.active)}
|
class={`${styles.tab} ${currentStory.currentTab === tab.id ? styles.active : ""}`}
|
||||||
onClick={() => handleTabChange(tab.id)}
|
onClick={() => handleTabChange(tab.id)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { createContext } from "preact";
|
||||||
import { useContext, useMemo, useReducer } from "preact/hooks";
|
import { useContext, useMemo, useReducer } from "preact/hooks";
|
||||||
|
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
import Chapters from "../utils/chapters";
|
|
||||||
import { useStoredReducer } from "@common/hooks/useStoredState";
|
import { useStoredReducer } from "@common/hooks/useStoredState";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -11,7 +10,7 @@ export type ChatMessage = LLM.ChatMessage & {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters";
|
export type Tab = "story" | "lore" | "characters" | "locations";
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,12 +49,12 @@ export interface Story {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
lastModifiedChunk: string;
|
||||||
lore: string;
|
lore: string;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
locations: Location[];
|
locations: Location[];
|
||||||
currentTab: Tab;
|
currentTab: Tab;
|
||||||
chatMessages: ChatMessage[];
|
chatMessages: ChatMessage[];
|
||||||
chapters: Chapters.Chapter[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -75,7 +74,7 @@ interface IState {
|
||||||
type Action =
|
type Action =
|
||||||
| { type: 'CREATE_STORY'; title: string }
|
| { type: 'CREATE_STORY'; title: string }
|
||||||
| { type: 'RENAME_STORY'; id: string; title: string }
|
| { type: 'RENAME_STORY'; id: string; title: string }
|
||||||
| { type: 'EDIT_STORY'; id: string; text: string }
|
| { type: 'EDIT_STORY'; id: string; text: string; lastModifiedChunk?: string }
|
||||||
| { type: 'EDIT_LORE'; id: string; lore: string }
|
| { type: 'EDIT_LORE'; id: string; lore: string }
|
||||||
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
|
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
|
||||||
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
|
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
|
||||||
|
|
@ -95,9 +94,7 @@ type Action =
|
||||||
| { 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: 'ADD_LOCATION'; storyId: string; location: Location }
|
||||||
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> }
|
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> }
|
||||||
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string }
|
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string };
|
||||||
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string }
|
|
||||||
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
|
|
||||||
|
|
||||||
// ─── Initial State ───────────────────────────────────────────────────────────
|
// ─── Initial State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -120,12 +117,12 @@ function reducer(state: IState, action: Action): IState {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: action.title,
|
title: action.title,
|
||||||
text: '',
|
text: '',
|
||||||
|
lastModifiedChunk: '',
|
||||||
lore: '',
|
lore: '',
|
||||||
characters: [],
|
characters: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
currentTab: 'story',
|
currentTab: 'story',
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
chapters: [],
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -145,7 +142,11 @@ function reducer(state: IState, action: Action): IState {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
stories: state.stories.map(s =>
|
stories: state.stories.map(s =>
|
||||||
s.id === action.id ? { ...s, text: action.text } : s
|
s.id === action.id ? {
|
||||||
|
...s,
|
||||||
|
text: action.text,
|
||||||
|
lastModifiedChunk: action.lastModifiedChunk ?? s.lastModifiedChunk
|
||||||
|
} : s
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -367,43 +368,6 @@ function reducer(state: IState, action: Action): IState {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'CLEAN_CHAPTER_SUMMARIES': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s => {
|
|
||||||
if (s.id !== action.storyId) return s;
|
|
||||||
const chapters = (s.chapters ?? [])
|
|
||||||
.filter(c => action.validHashes[c.header] !== undefined)
|
|
||||||
.map(c => {
|
|
||||||
const valid = new Set(action.validHashes[c.header]);
|
|
||||||
const summaryCache = Object.fromEntries(
|
|
||||||
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
|
|
||||||
);
|
|
||||||
return { ...c, summaryCache };
|
|
||||||
});
|
|
||||||
return { ...s, chapters };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'STORE_CHAPTER_SUMMARY': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s => {
|
|
||||||
if (s.id !== action.storyId) return s;
|
|
||||||
const chapters = s.chapters ?? [];
|
|
||||||
const existing = chapters.find(c => c.header === action.header);
|
|
||||||
const updated = existing
|
|
||||||
? Chapters.storeSummary(existing, action.hash, action.summary)
|
|
||||||
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
chapters: existing
|
|
||||||
? chapters.map(c => c.header === action.header ? updated : c)
|
|
||||||
: [...chapters, updated],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
namespace Chapters {
|
|
||||||
export type Hash = string;
|
|
||||||
|
|
||||||
/** Persisted per-story, maps content hash -> cached summary */
|
|
||||||
export interface Chapter {
|
|
||||||
header: string;
|
|
||||||
summaryCache: Record<Hash, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Transient result of parsing the story text */
|
|
||||||
export interface ParsedChapter {
|
|
||||||
header: string; // empty for preamble before first header
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hashChunk(text: string): Hash {
|
|
||||||
let h = 5381;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
h = (h * 33) ^ text.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return (h >>> 0).toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHUNK_TARGET_CHARS = 2400; // ~800 tokens
|
|
||||||
|
|
||||||
/** Split a chapter body into paragraph-grouped chunks for summarization */
|
|
||||||
export function splitIntoChunks(body: string): string[] {
|
|
||||||
const paragraphs = body.split(/\n{2,}/).map(p => p.trim()).filter(Boolean);
|
|
||||||
const chunks: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
|
|
||||||
for (const para of paragraphs) {
|
|
||||||
const sep = current ? '\n\n' : '';
|
|
||||||
if (current && current.length + sep.length + para.length > CHUNK_TARGET_CHARS) {
|
|
||||||
chunks.push(current);
|
|
||||||
current = para;
|
|
||||||
} else {
|
|
||||||
current = current + sep + para;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current) chunks.push(current);
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split story text into chapters by top-level `# ` headers */
|
|
||||||
export function parseText(text: string): ParsedChapter[] {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
const result: ParsedChapter[] = [];
|
|
||||||
let currentHeader = '';
|
|
||||||
let currentLines: string[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('# ')) {
|
|
||||||
if (currentHeader || currentLines.length > 0) {
|
|
||||||
result.push({ header: currentHeader, body: currentLines.join('\n').trim() });
|
|
||||||
}
|
|
||||||
currentHeader = line;
|
|
||||||
currentLines = [];
|
|
||||||
} else {
|
|
||||||
currentLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentHeader || currentLines.length > 0) {
|
|
||||||
result.push({ header: currentHeader, body: currentLines.join('\n').trim() });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look up a cached summary for a parsed chapter body.
|
|
||||||
* Returns the hash (for later cache writes) and the summary if it exists.
|
|
||||||
*/
|
|
||||||
export function lookupSummary(chapter: Chapter, body: string): { hash: Hash; summary: string | null } {
|
|
||||||
const h = hashChunk(body);
|
|
||||||
return { hash: h, summary: chapter.summaryCache[h] ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return a new Chapter with the summary stored under the given hash */
|
|
||||||
export function storeSummary(chapter: Chapter, hash: Hash, summary: string): Chapter {
|
|
||||||
return { ...chapter, summaryCache: { ...chapter.summaryCache, [hash]: summary } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create an empty Chapter record for a given header */
|
|
||||||
export function emptyChapter(header: string): Chapter {
|
|
||||||
return { header, summaryCache: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Chapters;
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import LLM from "./llm";
|
import LLM from "./llm";
|
||||||
import Chapters from "./chapters";
|
|
||||||
import { type AppState, LocationScale } from "../contexts/state";
|
import { type AppState, LocationScale } from "../contexts/state";
|
||||||
import { Tools } from "./tools";
|
import { Tools } from "./tools";
|
||||||
|
|
||||||
|
|
@ -8,106 +7,56 @@ namespace Prompt {
|
||||||
return text.length / 3;
|
return text.length / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEEP_RECENT_CHUNKS = 2;
|
export function formatStoryText(text: string, tokenBudget: number): string {
|
||||||
|
|
||||||
interface ChunkSlot {
|
|
||||||
header: string;
|
|
||||||
body: string;
|
|
||||||
summary: string | null;
|
|
||||||
mode: 'full' | 'summary' | 'omitted';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSlots(text: string, chapters: Chapters.Chapter[]): ChunkSlot[] {
|
|
||||||
const parsed = Chapters.parseText(text);
|
|
||||||
const slots: ChunkSlot[] = [];
|
|
||||||
|
|
||||||
for (const parsedChapter of parsed) {
|
|
||||||
const cachedChapter = chapters.find(c => c.header === parsedChapter.header)
|
|
||||||
?? Chapters.emptyChapter(parsedChapter.header);
|
|
||||||
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
|
|
||||||
|
|
||||||
for (const body of chunks) {
|
|
||||||
const { summary } = Chapters.lookupSummary(cachedChapter, body);
|
|
||||||
slots.push({ header: parsedChapter.header, body, summary, mode: 'full' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countSlotTokens(slots: ChunkSlot[]): number {
|
|
||||||
let total = 0;
|
|
||||||
const countedHeaders = new Set<string>();
|
|
||||||
|
|
||||||
for (const slot of slots) {
|
|
||||||
if (slot.mode === 'omitted') continue;
|
|
||||||
if (slot.header && !countedHeaders.has(slot.header)) {
|
|
||||||
total += approxTokens(slot.header);
|
|
||||||
countedHeaders.add(slot.header);
|
|
||||||
}
|
|
||||||
total += approxTokens(slot.mode === 'summary' ? (slot.summary ?? '') : slot.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSlots(slots: ChunkSlot[]): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
const shownHeaders = new Set<string>();
|
|
||||||
|
|
||||||
for (const slot of slots) {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
if (slot.header && !shownHeaders.has(slot.header)) {
|
|
||||||
lines.push(slot.header);
|
|
||||||
shownHeaders.add(slot.header);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = slot.mode === 'omitted' ? '[...]'
|
|
||||||
: slot.mode === 'summary' ? `[Summary: ${slot.summary}]`
|
|
||||||
: slot.body;
|
|
||||||
lines.push(content);
|
|
||||||
parts.push(lines.join('\n\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatStoryChunks(
|
|
||||||
text: string,
|
|
||||||
chapters: Chapters.Chapter[],
|
|
||||||
tokenBudget: number,
|
|
||||||
): string {
|
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
const slots = buildSlots(text, chapters);
|
if (approxTokens(text) <= tokenBudget / 2) {
|
||||||
if (slots.length === 0) return '';
|
return text;
|
||||||
|
|
||||||
if (countSlotTokens(slots) <= tokenBudget) {
|
|
||||||
return renderSlots(slots);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentStart = Math.max(0, slots.length - KEEP_RECENT_CHUNKS);
|
const lines = text.split('\n');
|
||||||
|
const separator = '[...]';
|
||||||
|
// Max chars for content = half-budget tokens * 3 chars/token, minus separator overhead
|
||||||
|
const targetChars = Math.floor(tokenBudget / 2 * 3) - separator.length - 2;
|
||||||
|
|
||||||
// Phase 1: summarize non-recent chunks, stop as soon as we fit
|
if (targetChars <= 0) {
|
||||||
for (let i = 0; i < recentStart; i++) {
|
return separator;
|
||||||
if (slots[i].summary) {
|
|
||||||
slots[i].mode = 'summary';
|
|
||||||
if (countSlotTokens(slots) <= tokenBudget) return renderSlots(slots);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: delete from middle outward, never delete last slot
|
// 1/3 of budget for start, 2/3 for end
|
||||||
const middle = recentStart / 2;
|
const startCharsMax = Math.floor(targetChars / 3);
|
||||||
const deletable = Array.from({ length: recentStart }, (_, i) => i)
|
const endCharsMax = targetChars - startCharsMax;
|
||||||
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
|
|
||||||
|
|
||||||
for (const i of deletable) {
|
let startCharsUsed = 0;
|
||||||
slots[i].mode = 'omitted';
|
let startEnd = 0;
|
||||||
if (countSlotTokens(slots) <= tokenBudget) break;
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const lineLen = lines[i].length + 1; // +1 for '\n'
|
||||||
|
if (startCharsUsed + lineLen > startCharsMax) break;
|
||||||
|
startCharsUsed += lineLen;
|
||||||
|
startEnd = i + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderSlots(slots);
|
let endCharsUsed = 0;
|
||||||
|
let endStart = lines.length;
|
||||||
|
for (let i = lines.length - 1; i >= startEnd; i--) {
|
||||||
|
const lineLen = lines[i].length + 1;
|
||||||
|
if (endCharsUsed + lineLen > endCharsMax) break;
|
||||||
|
endCharsUsed += lineLen;
|
||||||
|
endStart = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startEnd >= endStart) {
|
||||||
|
return text; // All lines fit after all
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPart = lines.slice(0, startEnd).join('\n');
|
||||||
|
const endPart = lines.slice(endStart).join('\n');
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (startPart) parts.push(startPart);
|
||||||
|
parts.push(separator);
|
||||||
|
if (endPart) parts.push(endPart);
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCharactersMarkdown(state: AppState): string {
|
export function formatCharactersMarkdown(state: AppState): string {
|
||||||
|
|
@ -189,13 +138,17 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStory.text && storyTokenBudget > 0) {
|
if (currentStory.text && storyTokenBudget > 0) {
|
||||||
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
|
const storyText = formatStoryText(currentStory.text, storyTokenBudget);
|
||||||
if (storyText) {
|
if (storyText) {
|
||||||
parts.push(`## Story\n${storyText}`);
|
parts.push(`## Story\n${storyText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join('\n\n');
|
if (currentStory.lastModifiedChunk) {
|
||||||
|
parts.push(`## Last Modified Chunk\n${currentStory.lastModifiedChunk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null {
|
export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export namespace Tools {
|
||||||
type: 'EDIT_STORY',
|
type: 'EDIT_STORY',
|
||||||
id: appState.currentStory.id,
|
id: appState.currentStory.id,
|
||||||
text: appState.currentStory.text + args.text,
|
text: appState.currentStory.text + args.text,
|
||||||
|
lastModifiedChunk: args.text
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'SET_CURRENT_TAB',
|
type: 'SET_CURRENT_TAB',
|
||||||
|
|
@ -245,6 +246,7 @@ export namespace Tools {
|
||||||
type: 'EDIT_STORY',
|
type: 'EDIT_STORY',
|
||||||
id: appState.currentStory.id,
|
id: appState.currentStory.id,
|
||||||
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
|
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
|
||||||
|
lastModifiedChunk: args.replace_all ? undefined : args.new_text,
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'SET_CURRENT_TAB',
|
type: 'SET_CURRENT_TAB',
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { useRef, useState } from 'preact/hooks';
|
|
||||||
import { useAppState, type AppState } from '../contexts/state';
|
|
||||||
import Chapters from './chapters';
|
|
||||||
import LLM from './llm';
|
|
||||||
|
|
||||||
export function useChapterSummarization() {
|
|
||||||
const state = useAppState();
|
|
||||||
const stateRef = useRef<AppState>(state);
|
|
||||||
stateRef.current = state;
|
|
||||||
|
|
||||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
|
||||||
|
|
||||||
const summarizeAll = async () => {
|
|
||||||
const { currentStory, connection, model, dispatch } = stateRef.current;
|
|
||||||
if (!currentStory || !connection || !model || isSummarizing) return;
|
|
||||||
|
|
||||||
setIsSummarizing(true);
|
|
||||||
try {
|
|
||||||
const parsed = Chapters.parseText(currentStory.text);
|
|
||||||
const validHashes: Record<string, Chapters.Hash[]> = {};
|
|
||||||
|
|
||||||
for (const parsedChapter of parsed) {
|
|
||||||
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
|
|
||||||
const cachedChapter = (currentStory.chapters ?? [])
|
|
||||||
.find(c => c.header === parsedChapter.header)
|
|
||||||
?? Chapters.emptyChapter(parsedChapter.header);
|
|
||||||
|
|
||||||
validHashes[parsedChapter.header] = [];
|
|
||||||
|
|
||||||
for (const body of chunks) {
|
|
||||||
const { hash, summary } = Chapters.lookupSummary(cachedChapter, body);
|
|
||||||
|
|
||||||
if (summary === null) {
|
|
||||||
const newSummary = await LLM.summarize(connection, model.id, body);
|
|
||||||
dispatch({
|
|
||||||
type: 'STORE_CHAPTER_SUMMARY',
|
|
||||||
storyId: currentStory.id,
|
|
||||||
header: parsedChapter.header,
|
|
||||||
hash,
|
|
||||||
summary: newSummary,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validHashes[parsedChapter.header].push(hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up stale cache entries
|
|
||||||
dispatch({
|
|
||||||
type: 'CLEAN_CHAPTER_SUMMARIES',
|
|
||||||
storyId: currentStory.id,
|
|
||||||
validHashes,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSummarizing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { summarizeAll, isSummarizing };
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue