1
0
Fork 0

Compare commits

..

2 Commits

Author SHA1 Message Date
Pabloader a605c95890 Summarization 2026-03-25 13:22:17 +00:00
Pabloader 5a6897f1f6 Chapters 2026-03-25 12:27:06 +00:00
13 changed files with 521 additions and 82 deletions

View File

@ -0,0 +1,10 @@
.autoLines {
overflow: hidden;
}
.root:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
font-style: italic;
pointer-events: none;
}

View File

@ -1,8 +1,12 @@
import { useEffect, useRef } from "preact/hooks";
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'> & {
value: string;
placeholder?: string;
autoLines?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
};
@ -47,7 +51,12 @@ function setCaretOffset(el: HTMLElement, offset: number) {
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);
useEffect(() => {
@ -57,11 +66,11 @@ export const ContentEditable = ({ value, onInput, ...props }: Props) => {
const offset = document.activeElement === el ? getCaretOffset(el) : null;
el.innerHTML = value;
if (offset !== null) setCaretOffset(el, offset);
if (autoLines) resizeToContent(el);
}, [value]);
const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key !== 'Enter') return;
const prevTextContent = (e.target as HTMLDivElement).textContent;
e.preventDefault();
const sel = window.getSelection();
@ -75,31 +84,25 @@ export const ContentEditable = ({ value, onInput, ...props }: Props) => {
range.setStartAfter(newline);
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.addRange(range);
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 (
<div
ref={ref}
contentEditable
onKeyDown={handleKeyDown}
onInput={onInput}
onInput={handleInput}
data-placeholder={placeholder}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
{...props}
/>
);

View File

@ -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);
}
}

View File

@ -133,10 +133,35 @@
}
.tokenCounter {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
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 {
display: flex;
align-items: center;

View File

@ -17,6 +17,7 @@
text-align: center;
}
.content {
flex: 1;
overflow-y: auto;
@ -65,7 +66,11 @@
display: flex;
border-top: 1px solid var(--border);
padding: 0 12px;
gap: 8px;
gap: 6px;
}
.tabRight {
margin-left: auto;
}
.tab {

View File

@ -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>
);
};

View File

@ -2,11 +2,13 @@ import { useInputState } from "@common/hooks/useInputState";
import { highlight } from "@common/highlight";
import { Sidebar } from "./sidebar";
import { useAppState, type ChatMessage } from "../contexts/state";
import { useChapterSummarization } from "../utils/useChapterSummarization";
import styles from '../assets/chat-sidebar.module.css';
import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks";
import LLM from "../utils/llm";
import Prompt from "../utils/prompt";
import { Tools } from "../utils/tools";
import { Sparkles } from "lucide-preact";
import clsx from "clsx";
// ─── Role Header ──────────────────────────────────────────────────────────────
@ -41,6 +43,7 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
export const ChatSidebar = () => {
const appState = useAppState();
const { currentStory, dispatch, connection, model, enableThinking } = appState;
const { summarizeAll, isSummarizing } = useChapterSummarization();
const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false);
const [isCollapsed, setCollapsed] = useState(false);
@ -336,11 +339,16 @@ export const ChatSidebar = () => {
/>
<span>Enable thinking</span>
</label>
{tokenCount && (
<div class={styles.tokenCounter}>
{tokenCount.taken} / {tokenCount.total} tokens
</div>
)}
<div class={styles.tokenCounter}>
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
<button
class={styles.summarizeButton}
onClick={summarizeAll}
disabled={isSummarizing || !currentStory || !connection || !model}
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
<Sparkles size={14} />
</button>
</div>
</div>
<textarea
class={styles.input}

View File

@ -3,11 +3,14 @@ import { highlight } from "@common/highlight";
import { useAppState, type Tab } from "../contexts/state";
import styles from '../assets/editor.module.css';
import { useMemo } from "preact/hooks";
import clsx from "clsx";
import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor";
import { ChaptersEditor } from "./chapters-editor";
const TABS: { id: Tab; label: string }[] = [
{ id: "story", label: "Story" },
{ id: "chapters", label: "Chapters" },
{ id: "lore", label: "Lore" },
{ id: "characters", label: "Characters" },
{ id: "locations", label: "Locations" },
@ -51,7 +54,9 @@ export const Editor = () => {
return (
<div class={styles.editor}>
<div class={styles.title}>{currentStory.title}</div>
<div class={styles.title}>
{currentStory.title}
</div>
<div class={styles.content}>
{currentStory.currentTab === "story" && (
<ContentEditable
@ -75,12 +80,15 @@ export const Editor = () => {
{currentStory.currentTab === "locations" && (
<LocationEditor />
)}
{currentStory.currentTab === "chapters" && (
<ChaptersEditor />
)}
</div>
<div class={styles.tabs}>
{TABS.map((tab) => (
<button
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)}
>
{tab.label}

View File

@ -2,6 +2,7 @@ import { createContext } from "preact";
import { useContext, useMemo, useReducer } from "preact/hooks";
import LLM from "../utils/llm";
import Chapters from "../utils/chapters";
import { useStoredReducer } from "@common/hooks/useStoredState";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -10,7 +11,7 @@ export type ChatMessage = LLM.ChatMessage & {
id: string;
}
export type Tab = "story" | "lore" | "characters" | "locations";
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters";
export interface Character {
id: string;
@ -49,12 +50,12 @@ export interface Story {
id: string;
title: string;
text: string;
lastModifiedChunk: string;
lore: string;
characters: Character[];
locations: Location[];
currentTab: Tab;
chatMessages: ChatMessage[];
chapters: Chapters.Chapter[];
}
// ─── State ───────────────────────────────────────────────────────────────────
@ -74,7 +75,7 @@ interface IState {
type Action =
| { type: 'CREATE_STORY'; 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: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { 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: 'ADD_LOCATION'; storyId: string; location: 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 ───────────────────────────────────────────────────────────
@ -117,12 +120,12 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(),
title: action.title,
text: '',
lastModifiedChunk: '',
lore: '',
characters: [],
locations: [],
currentTab: 'story',
chatMessages: [],
chapters: [],
};
return {
...state,
@ -142,11 +145,7 @@ function reducer(state: IState, action: Action): IState {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? {
...s,
text: action.text,
lastModifiedChunk: action.lastModifiedChunk ?? s.lastModifiedChunk
} : s
s.id === action.id ? { ...s, text: action.text } : 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],
};
}),
};
}
}
}

View File

@ -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;

View File

@ -1,4 +1,5 @@
import LLM from "./llm";
import Chapters from "./chapters";
import { type AppState, LocationScale } from "../contexts/state";
import { Tools } from "./tools";
@ -7,56 +8,106 @@ namespace Prompt {
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 (approxTokens(text) <= tokenBudget / 2) {
return text;
const slots = buildSlots(text, chapters);
if (slots.length === 0) return '';
if (countSlotTokens(slots) <= tokenBudget) {
return renderSlots(slots);
}
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;
const recentStart = Math.max(0, slots.length - KEEP_RECENT_CHUNKS);
if (targetChars <= 0) {
return separator;
// Phase 1: summarize non-recent chunks, stop as soon as we fit
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
const startCharsMax = Math.floor(targetChars / 3);
const endCharsMax = targetChars - startCharsMax;
// Phase 2: delete from middle outward, never delete last slot
const middle = recentStart / 2;
const deletable = Array.from({ length: recentStart }, (_, i) => i)
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
let startCharsUsed = 0;
let startEnd = 0;
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;
for (const i of deletable) {
slots[i].mode = 'omitted';
if (countSlotTokens(slots) <= tokenBudget) break;
}
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');
return renderSlots(slots);
}
export function formatCharactersMarkdown(state: AppState): string {
@ -138,17 +189,13 @@ namespace Prompt {
}
if (currentStory.text && storyTokenBudget > 0) {
const storyText = formatStoryText(currentStory.text, storyTokenBudget);
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
if (storyText) {
parts.push(`## Story\n${storyText}`);
}
}
if (currentStory.lastModifiedChunk) {
parts.push(`## Last Modified Chunk\n${currentStory.lastModifiedChunk}`);
}
return parts.join('\n\n');
return parts.join('\n\n');
}
export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null {

View File

@ -29,7 +29,6 @@ export namespace Tools {
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text + args.text,
lastModifiedChunk: args.text
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
@ -246,7 +245,6 @@ export namespace Tools {
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
lastModifiedChunk: args.replace_all ? undefined : args.new_text,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',

View File

@ -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 };
}