Compare commits
2 Commits
bbe5716e40
...
a605c95890
| Author | SHA1 | Date |
|---|---|---|
|
|
a605c95890 | |
|
|
5a6897f1f6 |
|
|
@ -0,0 +1,10 @@
|
||||||
|
.autoLines {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
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>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,7 +51,12 @@ function setCaretOffset(el: HTMLElement, offset: number) {
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentEditable = ({ value, onInput, ...props }: Props) => {
|
function resizeToContent(el: HTMLElement) {
|
||||||
|
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(() => {
|
||||||
|
|
@ -57,11 +66,11 @@ export const ContentEditable = ({ value, onInput, ...props }: Props) => {
|
||||||
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();
|
||||||
|
|
@ -75,31 +84,25 @@ export const ContentEditable = ({ value, onInput, ...props }: Props) => {
|
||||||
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={onInput}
|
onInput={handleInput}
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
.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,10 +133,35 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.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,6 +17,7 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -65,7 +66,11 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabRight {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
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,11 +2,13 @@ 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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -41,6 +43,7 @@ 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);
|
||||||
|
|
@ -336,11 +339,16 @@ export const ChatSidebar = () => {
|
||||||
/>
|
/>
|
||||||
<span>Enable thinking</span>
|
<span>Enable thinking</span>
|
||||||
</label>
|
</label>
|
||||||
{tokenCount && (
|
<div class={styles.tokenCounter}>
|
||||||
<div class={styles.tokenCounter}>
|
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
||||||
{tokenCount.taken} / {tokenCount.total} tokens
|
<button
|
||||||
</div>
|
class={styles.summarizeButton}
|
||||||
)}
|
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,11 +3,14 @@ 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" },
|
||||||
|
|
@ -51,7 +54,9 @@ export const Editor = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.editor}>
|
<div class={styles.editor}>
|
||||||
<div class={styles.title}>{currentStory.title}</div>
|
<div class={styles.title}>
|
||||||
|
{currentStory.title}
|
||||||
|
</div>
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
{currentStory.currentTab === "story" && (
|
{currentStory.currentTab === "story" && (
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
|
|
@ -75,12 +80,15 @@ 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={`${styles.tab} ${currentStory.currentTab === tab.id ? styles.active : ""}`}
|
class={clsx(styles.tab, currentStory.currentTab === tab.id && styles.active)}
|
||||||
onClick={() => handleTabChange(tab.id)}
|
onClick={() => handleTabChange(tab.id)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -10,7 +11,7 @@ export type ChatMessage = LLM.ChatMessage & {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tab = "story" | "lore" | "characters" | "locations";
|
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters";
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -49,12 +50,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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -74,7 +75,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; lastModifiedChunk?: string }
|
| { type: 'EDIT_STORY'; id: string; text: 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 }
|
||||||
|
|
@ -94,7 +95,9 @@ 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 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -117,12 +120,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,
|
||||||
|
|
@ -142,11 +145,7 @@ 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.id === action.id ? { ...s, text: action.text } : s
|
||||||
...s,
|
|
||||||
text: action.text,
|
|
||||||
lastModifiedChunk: action.lastModifiedChunk ?? s.lastModifiedChunk
|
|
||||||
} : s
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -368,6 +367,43 @@ 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],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
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,4 +1,5 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -7,56 +8,106 @@ namespace Prompt {
|
||||||
return text.length / 3;
|
return text.length / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatStoryText(text: string, tokenBudget: number): string {
|
const KEEP_RECENT_CHUNKS = 2;
|
||||||
|
|
||||||
|
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 '';
|
||||||
|
|
||||||
if (approxTokens(text) <= tokenBudget / 2) {
|
const slots = buildSlots(text, chapters);
|
||||||
return text;
|
if (slots.length === 0) return '';
|
||||||
|
|
||||||
|
if (countSlotTokens(slots) <= tokenBudget) {
|
||||||
|
return renderSlots(slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = text.split('\n');
|
const recentStart = Math.max(0, slots.length - KEEP_RECENT_CHUNKS);
|
||||||
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;
|
|
||||||
|
|
||||||
if (targetChars <= 0) {
|
// Phase 1: summarize non-recent chunks, stop as soon as we fit
|
||||||
return separator;
|
for (let i = 0; i < recentStart; i++) {
|
||||||
|
if (slots[i].summary) {
|
||||||
|
slots[i].mode = 'summary';
|
||||||
|
if (countSlotTokens(slots) <= tokenBudget) return renderSlots(slots);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/3 of budget for start, 2/3 for end
|
// Phase 2: delete from middle outward, never delete last slot
|
||||||
const startCharsMax = Math.floor(targetChars / 3);
|
const middle = recentStart / 2;
|
||||||
const endCharsMax = targetChars - startCharsMax;
|
const deletable = Array.from({ length: recentStart }, (_, i) => i)
|
||||||
|
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
|
||||||
|
|
||||||
let startCharsUsed = 0;
|
for (const i of deletable) {
|
||||||
let startEnd = 0;
|
slots[i].mode = 'omitted';
|
||||||
for (let i = 0; i < lines.length; i++) {
|
if (countSlotTokens(slots) <= tokenBudget) break;
|
||||||
const lineLen = lines[i].length + 1; // +1 for '\n'
|
|
||||||
if (startCharsUsed + lineLen > startCharsMax) break;
|
|
||||||
startCharsUsed += lineLen;
|
|
||||||
startEnd = i + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let endCharsUsed = 0;
|
return renderSlots(slots);
|
||||||
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 {
|
||||||
|
|
@ -138,17 +189,13 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStory.text && storyTokenBudget > 0) {
|
if (currentStory.text && storyTokenBudget > 0) {
|
||||||
const storyText = formatStoryText(currentStory.text, storyTokenBudget);
|
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
|
||||||
if (storyText) {
|
if (storyText) {
|
||||||
parts.push(`## Story\n${storyText}`);
|
parts.push(`## Story\n${storyText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStory.lastModifiedChunk) {
|
return parts.join('\n\n');
|
||||||
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,7 +29,6 @@ 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',
|
||||||
|
|
@ -246,7 +245,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
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