Chapters
This commit is contained in:
parent
bbe5716e40
commit
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,16 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summarizing {
|
||||||
|
display: block;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -65,7 +75,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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
import { highlight } from "@common/highlight";
|
import { highlight } from "@common/highlight";
|
||||||
import { useAppState, type Tab } from "../contexts/state";
|
import { useAppState, type Tab } from "../contexts/state";
|
||||||
|
import { useChapterSummarization } from "../utils/useChapterSummarization";
|
||||||
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 { Pause, Play } from "lucide-preact";
|
||||||
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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Editor = () => {
|
export const Editor = () => {
|
||||||
const { currentStory, dispatch } = useAppState();
|
const { currentStory, summarizationPaused, dispatch } = useAppState();
|
||||||
|
const { pendingCount } = useChapterSummarization();
|
||||||
|
|
||||||
if (!currentStory) {
|
if (!currentStory) {
|
||||||
return <div class={styles.editor} />;
|
return <div class={styles.editor} />;
|
||||||
|
|
@ -51,7 +57,12 @@ 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}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span class={styles.summarizing}>Summarizing ({pendingCount})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
{currentStory.currentTab === "story" && (
|
{currentStory.currentTab === "story" && (
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
|
|
@ -75,17 +86,29 @@ 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}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
class={clsx(styles.tab, styles.tabRight, summarizationPaused && styles.active)}
|
||||||
|
onClick={() => dispatch({ type: 'SET_SUMMARIZATION_PAUSED', paused: !summarizationPaused })}
|
||||||
|
>
|
||||||
|
{summarizationPaused
|
||||||
|
? <><Play size={14} /> Summarization paused</>
|
||||||
|
: <><Pause size={14} /> Pause summarization</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -67,6 +68,7 @@ interface IState {
|
||||||
enableThinking: boolean;
|
enableThinking: boolean;
|
||||||
bannedTokens: string[];
|
bannedTokens: string[];
|
||||||
systemInstruction: string;
|
systemInstruction: string;
|
||||||
|
summarizationPaused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -74,7 +76,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 +96,10 @@ 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: 'SET_SUMMARIZATION_PAUSED'; paused: boolean }
|
||||||
|
| { 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 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -106,6 +111,7 @@ const DEFAULT_STATE: IState = {
|
||||||
enableThinking: false,
|
enableThinking: false,
|
||||||
bannedTokens: [],
|
bannedTokens: [],
|
||||||
systemInstruction: `You are a creative writing assistant. Help the user develop their story by writing engaging content, maintaining consistency with the established characters, settings, and plot. Follow the user's instructions while staying true to the story's tone and style.`,
|
systemInstruction: `You are a creative writing assistant. Help the user develop their story by writing engaging content, maintaining consistency with the established characters, settings, and plot. Follow the user's instructions while staying true to the story's tone and style.`,
|
||||||
|
summarizationPaused: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -117,12 +123,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 +148,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 +370,46 @@ 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 'SET_SUMMARIZATION_PAUSED': {
|
||||||
|
return { ...state, summarizationPaused: action.paused };
|
||||||
|
}
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,6 +423,7 @@ export interface AppState {
|
||||||
enableThinking: boolean;
|
enableThinking: boolean;
|
||||||
bannedTokens: string[];
|
bannedTokens: string[];
|
||||||
systemInstruction: string;
|
systemInstruction: string;
|
||||||
|
summarizationPaused: boolean;
|
||||||
dispatch: (action: Action) => void;
|
dispatch: (action: Action) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,6 +444,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
enableThinking: state.enableThinking,
|
enableThinking: state.enableThinking,
|
||||||
bannedTokens: state.bannedTokens ?? [],
|
bannedTokens: state.bannedTokens ?? [],
|
||||||
systemInstruction: state.systemInstruction ?? '',
|
systemInstruction: state.systemInstruction ?? '',
|
||||||
|
summarizationPaused: state.summarizationPaused ?? false,
|
||||||
dispatch,
|
dispatch,
|
||||||
}), [state]);
|
}), [state]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -144,11 +144,7 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,123 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useAppState, type AppState } from '../contexts/state';
|
||||||
|
import Chapters from './chapters';
|
||||||
|
import LLM from './llm';
|
||||||
|
|
||||||
|
interface SummarizationJob {
|
||||||
|
storyId: string;
|
||||||
|
header: string;
|
||||||
|
index: number;
|
||||||
|
body: string;
|
||||||
|
hash: Chapters.Hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 2000;
|
||||||
|
|
||||||
|
export function useChapterSummarization() {
|
||||||
|
const state = useAppState();
|
||||||
|
|
||||||
|
// Always-fresh ref so async processQueue reads current connection/model/dispatch
|
||||||
|
const stateRef = useRef<AppState>(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
const queueRef = useRef<SummarizationJob[]>([]);
|
||||||
|
const processingRef = useRef(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
|
const processQueue = async () => {
|
||||||
|
if (processingRef.current) return;
|
||||||
|
processingRef.current = true;
|
||||||
|
try {
|
||||||
|
while (queueRef.current.length > 0) {
|
||||||
|
const { connection, model, dispatch, summarizationPaused } = stateRef.current;
|
||||||
|
if (!connection || !model || summarizationPaused) break;
|
||||||
|
|
||||||
|
const job = queueRef.current[0];
|
||||||
|
setPendingCount(queueRef.current.length);
|
||||||
|
|
||||||
|
queueRef.current = queueRef.current.slice(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await LLM.summarize(connection, model.id, job.body);
|
||||||
|
dispatch({
|
||||||
|
type: 'STORE_CHAPTER_SUMMARY',
|
||||||
|
storyId: job.storyId,
|
||||||
|
header: job.header,
|
||||||
|
hash: job.hash,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip failed job, continue with rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processingRef.current = false;
|
||||||
|
setPendingCount(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueue = (jobs: SummarizationJob[]) => {
|
||||||
|
for (const job of jobs) {
|
||||||
|
const idx = queueRef.current.findIndex(
|
||||||
|
j => j.header === job.header && j.index === job.index
|
||||||
|
);
|
||||||
|
if (idx !== -1) {
|
||||||
|
queueRef.current[idx] = job;
|
||||||
|
} else {
|
||||||
|
queueRef.current.push(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPendingCount(queueRef.current.length);
|
||||||
|
processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-scan when text changes (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const { currentStory } = stateRef.current;
|
||||||
|
if (!currentStory?.text) return;
|
||||||
|
|
||||||
|
const parsed = Chapters.parseText(currentStory.text);
|
||||||
|
const jobs: SummarizationJob[] = [];
|
||||||
|
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 (let i = 0; i < chunks.length; i++) {
|
||||||
|
const { hash, summary } = Chapters.lookupSummary(cachedChapter, chunks[i]);
|
||||||
|
validHashes[parsedChapter.header].push(hash);
|
||||||
|
if (summary === null) {
|
||||||
|
jobs.push({ storyId: currentStory.id, header: parsedChapter.header, index: i, body: chunks[i], hash });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stateRef.current.dispatch({ type: 'CLEAN_CHAPTER_SUMMARIES', storyId: currentStory.id, validHashes });
|
||||||
|
if (jobs.length > 0) {
|
||||||
|
enqueue(jobs);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [state.currentStory?.text]);
|
||||||
|
|
||||||
|
// Resume processing if connection/model become available or summarization is unpaused
|
||||||
|
useEffect(() => {
|
||||||
|
if (queueRef.current.length > 0) {
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
}, [state.connection, state.model, state.summarizationPaused]);
|
||||||
|
|
||||||
|
return { pendingCount };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue