1
0
Fork 0

Compare commits

..

No commits in common. "a605c9589057f66ac4daceabb9e71ac6040530b3" and "bbe5716e40945512f99fd2c73c661cb495a070d0" have entirely different histories.

13 changed files with 82 additions and 521 deletions

View File

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

View File

@ -1,12 +1,8 @@
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact"; import type { JSX } from "preact";
import clsx from "clsx";
import styles from "../assets/content-editable.module.css";
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & { type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
value: string; value: string;
placeholder?: string;
autoLines?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>; onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
}; };
@ -51,12 +47,7 @@ function setCaretOffset(el: HTMLElement, offset: number) {
sel.addRange(range); sel.addRange(range);
} }
function resizeToContent(el: HTMLElement) { export const ContentEditable = ({ value, onInput, ...props }: Props) => {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
export const ContentEditable = ({ value, placeholder, autoLines, onInput, class: externalClass, ...props }: Props) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@ -66,11 +57,11 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class:
const offset = document.activeElement === el ? getCaretOffset(el) : null; const offset = document.activeElement === el ? getCaretOffset(el) : null;
el.innerHTML = value; el.innerHTML = value;
if (offset !== null) setCaretOffset(el, offset); if (offset !== null) setCaretOffset(el, offset);
if (autoLines) resizeToContent(el);
}, [value]); }, [value]);
const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => { const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key !== 'Enter') return; if (e.key !== 'Enter') return;
const prevTextContent = (e.target as HTMLDivElement).textContent;
e.preventDefault(); e.preventDefault();
const sel = window.getSelection(); const sel = window.getSelection();
@ -84,25 +75,31 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class:
range.setStartAfter(newline); range.setStartAfter(newline);
range.collapse(true); range.collapse(true);
const nextTextContent = (e.target as HTMLDivElement).textContent;
// A trailing \n needs a following character to render in pre-line.
// If nothing follows the inserted newline, add a sentinel \n so the
// new line is visible, then place the caret before it.
const atEnd = nextTextContent.startsWith(prevTextContent) && nextTextContent !== prevTextContent && nextTextContent.length === prevTextContent.length + 1 && nextTextContent.at(-1) === '\n';
if (atEnd) {
const sentinel = document.createTextNode('\n');
range.insertNode(sentinel);
range.setStartBefore(sentinel);
range.collapse(true);
}
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true })); ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true }));
}; };
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
if (autoLines && ref.current) resizeToContent(ref.current);
onInput?.(e);
};
return ( return (
<div <div
ref={ref} ref={ref}
contentEditable contentEditable
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onInput={handleInput} onInput={onInput}
data-placeholder={placeholder}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
{...props} {...props}
/> />
); );

View File

@ -1,81 +0,0 @@
.chaptersEditor {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty {
color: var(--text-muted);
font-style: italic;
font-size: 14px;
}
.chapterCard {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.chapterTitle {
font-size: 18px;
font-weight: 600;
color: var(--text);
font-family: 'Georgia', serif;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.chunk {
display: flex;
flex-direction: column;
gap: 8px;
}
.chunkHeader {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chunkPreview {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
line-height: 1.5;
padding: 8px 12px;
background: var(--bg);
border-radius: 4px;
border-left: 2px solid var(--border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summaryEditable {
width: 100%;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
color: var(--text);
font-family: inherit;
box-sizing: border-box;
min-height: 80px;
white-space: pre-wrap;
word-wrap: break-word;
&:focus {
outline: none;
border-color: var(--accent);
}
}

View File

@ -133,35 +133,10 @@
} }
.tokenCounter { .tokenCounter {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
} }
.summarizeButton {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-muted);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
.toggleContainer { .toggleContainer {
display: flex; display: flex;
align-items: center; align-items: center;

View File

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

View File

@ -1,66 +0,0 @@
import { useMemo } from "preact/hooks";
import { useAppState } from "../contexts/state";
import Chapters from "../utils/chapters";
import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import styles from "../assets/chapters-editor.module.css";
export const ChaptersEditor = () => {
const { currentStory, dispatch } = useAppState();
if (!currentStory) return null;
const parsed = useMemo(
() => Chapters.parseText(currentStory.text),
[currentStory.text]
);
if (parsed.length === 0) {
return (
<div class={styles.chaptersEditor}>
<p class={styles.empty}>No chapters yet. Use <code># Chapter Title</code> headers in your story to create chapters.</p>
</div>
);
}
return (
<div class={styles.chaptersEditor}>
{parsed.map((parsedChapter) => {
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
const cachedChapter = (currentStory.chapters ?? []).find(c => c.header === parsedChapter.header)
?? Chapters.emptyChapter(parsedChapter.header);
return (
<div class={styles.chapterCard} key={parsedChapter.header || '__preamble__'}>
<div class={styles.chapterTitle}>
{parsedChapter.header ? parsedChapter.header.replace(/^# /, '') : '(Preamble)'}
</div>
{chunks.map((body, i) => {
const { hash, summary } = Chapters.lookupSummary(cachedChapter, body);
return (
<div class={styles.chunk} key={i}>
{chunks.length > 1 && (
<div class={styles.chunkHeader}>Part {i + 1}</div>
)}
<div class={styles.chunkPreview}>{body}</div>
<ContentEditable
class={styles.summaryEditable}
value={highlight(summary ?? '')}
placeholder="Not summarized yet..."
onInput={(e) => dispatch({
type: 'STORE_CHAPTER_SUMMARY',
storyId: currentStory.id,
header: parsedChapter.header,
hash,
summary: (e.target as HTMLDivElement).textContent || '',
})}
/>
</div>
);
})}
</div>
);
})}
</div>
);
};

View File

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

View File

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

View File

@ -2,7 +2,6 @@ import { createContext } from "preact";
import { useContext, useMemo, useReducer } from "preact/hooks"; import { useContext, useMemo, useReducer } from "preact/hooks";
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import Chapters from "../utils/chapters";
import { useStoredReducer } from "@common/hooks/useStoredState"; import { useStoredReducer } from "@common/hooks/useStoredState";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -11,7 +10,7 @@ export type ChatMessage = LLM.ChatMessage & {
id: string; id: string;
} }
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters"; export type Tab = "story" | "lore" | "characters" | "locations";
export interface Character { export interface Character {
id: string; id: string;
@ -50,12 +49,12 @@ export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
lastModifiedChunk: string;
lore: string; lore: string;
characters: Character[]; characters: Character[];
locations: Location[]; locations: Location[];
currentTab: Tab; currentTab: Tab;
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
chapters: Chapters.Chapter[];
} }
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
@ -75,7 +74,7 @@ interface IState {
type Action = type Action =
| { type: 'CREATE_STORY'; title: string } | { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string } | { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string } | { type: 'EDIT_STORY'; id: string; text: string; lastModifiedChunk?: string }
| { type: 'EDIT_LORE'; id: string; lore: string } | { type: 'EDIT_LORE'; id: string; lore: string }
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab } | { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
@ -95,9 +94,7 @@ type Action =
| { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string } | { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string }
| { type: 'ADD_LOCATION'; storyId: string; location: Location } | { type: 'ADD_LOCATION'; storyId: string; location: Location }
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> } | { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> }
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string } | { type: 'DELETE_LOCATION'; storyId: string; locationId: string };
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string }
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
// ─── Initial State ─────────────────────────────────────────────────────────── // ─── Initial State ───────────────────────────────────────────────────────────
@ -120,12 +117,12 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: action.title, title: action.title,
text: '', text: '',
lastModifiedChunk: '',
lore: '', lore: '',
characters: [], characters: [],
locations: [], locations: [],
currentTab: 'story', currentTab: 'story',
chatMessages: [], chatMessages: [],
chapters: [],
}; };
return { return {
...state, ...state,
@ -145,7 +142,11 @@ function reducer(state: IState, action: Action): IState {
return { return {
...state, ...state,
stories: state.stories.map(s => stories: state.stories.map(s =>
s.id === action.id ? { ...s, text: action.text } : s s.id === action.id ? {
...s,
text: action.text,
lastModifiedChunk: action.lastModifiedChunk ?? s.lastModifiedChunk
} : s
), ),
}; };
} }
@ -367,43 +368,6 @@ function reducer(state: IState, action: Action): IState {
), ),
}; };
} }
case 'CLEAN_CHAPTER_SUMMARIES': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const chapters = (s.chapters ?? [])
.filter(c => action.validHashes[c.header] !== undefined)
.map(c => {
const valid = new Set(action.validHashes[c.header]);
const summaryCache = Object.fromEntries(
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
);
return { ...c, summaryCache };
});
return { ...s, chapters };
}),
};
}
case 'STORE_CHAPTER_SUMMARY': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const chapters = s.chapters ?? [];
const existing = chapters.find(c => c.header === action.header);
const updated = existing
? Chapters.storeSummary(existing, action.hash, action.summary)
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
return {
...s,
chapters: existing
? chapters.map(c => c.header === action.header ? updated : c)
: [...chapters, updated],
};
}),
};
}
} }
} }

View File

@ -1,92 +0,0 @@
namespace Chapters {
export type Hash = string;
/** Persisted per-story, maps content hash -> cached summary */
export interface Chapter {
header: string;
summaryCache: Record<Hash, string>;
}
/** Transient result of parsing the story text */
export interface ParsedChapter {
header: string; // empty for preamble before first header
body: string;
}
export function hashChunk(text: string): Hash {
let h = 5381;
for (let i = 0; i < text.length; i++) {
h = (h * 33) ^ text.charCodeAt(i);
}
return (h >>> 0).toString(36);
}
const CHUNK_TARGET_CHARS = 2400; // ~800 tokens
/** Split a chapter body into paragraph-grouped chunks for summarization */
export function splitIntoChunks(body: string): string[] {
const paragraphs = body.split(/\n{2,}/).map(p => p.trim()).filter(Boolean);
const chunks: string[] = [];
let current = '';
for (const para of paragraphs) {
const sep = current ? '\n\n' : '';
if (current && current.length + sep.length + para.length > CHUNK_TARGET_CHARS) {
chunks.push(current);
current = para;
} else {
current = current + sep + para;
}
}
if (current) chunks.push(current);
return chunks;
}
/** Split story text into chapters by top-level `# ` headers */
export function parseText(text: string): ParsedChapter[] {
const lines = text.split('\n');
const result: ParsedChapter[] = [];
let currentHeader = '';
let currentLines: string[] = [];
for (const line of lines) {
if (line.startsWith('# ')) {
if (currentHeader || currentLines.length > 0) {
result.push({ header: currentHeader, body: currentLines.join('\n').trim() });
}
currentHeader = line;
currentLines = [];
} else {
currentLines.push(line);
}
}
if (currentHeader || currentLines.length > 0) {
result.push({ header: currentHeader, body: currentLines.join('\n').trim() });
}
return result;
}
/**
* Look up a cached summary for a parsed chapter body.
* Returns the hash (for later cache writes) and the summary if it exists.
*/
export function lookupSummary(chapter: Chapter, body: string): { hash: Hash; summary: string | null } {
const h = hashChunk(body);
return { hash: h, summary: chapter.summaryCache[h] ?? null };
}
/** Return a new Chapter with the summary stored under the given hash */
export function storeSummary(chapter: Chapter, hash: Hash, summary: string): Chapter {
return { ...chapter, summaryCache: { ...chapter.summaryCache, [hash]: summary } };
}
/** Create an empty Chapter record for a given header */
export function emptyChapter(header: string): Chapter {
return { header, summaryCache: {} };
}
}
export default Chapters;

View File

@ -1,5 +1,4 @@
import LLM from "./llm"; import LLM from "./llm";
import Chapters from "./chapters";
import { type AppState, LocationScale } from "../contexts/state"; import { type AppState, LocationScale } from "../contexts/state";
import { Tools } from "./tools"; import { Tools } from "./tools";
@ -8,106 +7,56 @@ namespace Prompt {
return text.length / 3; return text.length / 3;
} }
const KEEP_RECENT_CHUNKS = 2; export function formatStoryText(text: string, tokenBudget: number): string {
interface ChunkSlot {
header: string;
body: string;
summary: string | null;
mode: 'full' | 'summary' | 'omitted';
}
function buildSlots(text: string, chapters: Chapters.Chapter[]): ChunkSlot[] {
const parsed = Chapters.parseText(text);
const slots: ChunkSlot[] = [];
for (const parsedChapter of parsed) {
const cachedChapter = chapters.find(c => c.header === parsedChapter.header)
?? Chapters.emptyChapter(parsedChapter.header);
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
for (const body of chunks) {
const { summary } = Chapters.lookupSummary(cachedChapter, body);
slots.push({ header: parsedChapter.header, body, summary, mode: 'full' });
}
}
return slots;
}
function countSlotTokens(slots: ChunkSlot[]): number {
let total = 0;
const countedHeaders = new Set<string>();
for (const slot of slots) {
if (slot.mode === 'omitted') continue;
if (slot.header && !countedHeaders.has(slot.header)) {
total += approxTokens(slot.header);
countedHeaders.add(slot.header);
}
total += approxTokens(slot.mode === 'summary' ? (slot.summary ?? '') : slot.body);
}
return total;
}
function renderSlots(slots: ChunkSlot[]): string {
const parts: string[] = [];
const shownHeaders = new Set<string>();
for (const slot of slots) {
const lines: string[] = [];
if (slot.header && !shownHeaders.has(slot.header)) {
lines.push(slot.header);
shownHeaders.add(slot.header);
}
const content = slot.mode === 'omitted' ? '[...]'
: slot.mode === 'summary' ? `[Summary: ${slot.summary}]`
: slot.body;
lines.push(content);
parts.push(lines.join('\n\n'));
}
return parts.join('\n\n');
}
export function formatStoryChunks(
text: string,
chapters: Chapters.Chapter[],
tokenBudget: number,
): string {
if (!text) return ''; if (!text) return '';
const slots = buildSlots(text, chapters); if (approxTokens(text) <= tokenBudget / 2) {
if (slots.length === 0) return ''; return text;
if (countSlotTokens(slots) <= tokenBudget) {
return renderSlots(slots);
} }
const recentStart = Math.max(0, slots.length - KEEP_RECENT_CHUNKS); const lines = text.split('\n');
const separator = '[...]';
// Max chars for content = half-budget tokens * 3 chars/token, minus separator overhead
const targetChars = Math.floor(tokenBudget / 2 * 3) - separator.length - 2;
// Phase 1: summarize non-recent chunks, stop as soon as we fit if (targetChars <= 0) {
for (let i = 0; i < recentStart; i++) { return separator;
if (slots[i].summary) {
slots[i].mode = 'summary';
if (countSlotTokens(slots) <= tokenBudget) return renderSlots(slots);
}
} }
// Phase 2: delete from middle outward, never delete last slot // 1/3 of budget for start, 2/3 for end
const middle = recentStart / 2; const startCharsMax = Math.floor(targetChars / 3);
const deletable = Array.from({ length: recentStart }, (_, i) => i) const endCharsMax = targetChars - startCharsMax;
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
for (const i of deletable) { let startCharsUsed = 0;
slots[i].mode = 'omitted'; let startEnd = 0;
if (countSlotTokens(slots) <= tokenBudget) break; for (let i = 0; i < lines.length; i++) {
const lineLen = lines[i].length + 1; // +1 for '\n'
if (startCharsUsed + lineLen > startCharsMax) break;
startCharsUsed += lineLen;
startEnd = i + 1;
} }
return renderSlots(slots); let endCharsUsed = 0;
let endStart = lines.length;
for (let i = lines.length - 1; i >= startEnd; i--) {
const lineLen = lines[i].length + 1;
if (endCharsUsed + lineLen > endCharsMax) break;
endCharsUsed += lineLen;
endStart = i;
}
if (startEnd >= endStart) {
return text; // All lines fit after all
}
const startPart = lines.slice(0, startEnd).join('\n');
const endPart = lines.slice(endStart).join('\n');
const parts: string[] = [];
if (startPart) parts.push(startPart);
parts.push(separator);
if (endPart) parts.push(endPart);
return parts.join('\n');
} }
export function formatCharactersMarkdown(state: AppState): string { export function formatCharactersMarkdown(state: AppState): string {
@ -189,13 +138,17 @@ namespace Prompt {
} }
if (currentStory.text && storyTokenBudget > 0) { if (currentStory.text && storyTokenBudget > 0) {
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); const storyText = formatStoryText(currentStory.text, storyTokenBudget);
if (storyText) { if (storyText) {
parts.push(`## Story\n${storyText}`); parts.push(`## Story\n${storyText}`);
} }
} }
return parts.join('\n\n'); if (currentStory.lastModifiedChunk) {
parts.push(`## Last Modified Chunk\n${currentStory.lastModifiedChunk}`);
}
return parts.join('\n\n');
} }
export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null { export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null {

View File

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

View File

@ -1,60 +0,0 @@
import { useRef, useState } from 'preact/hooks';
import { useAppState, type AppState } from '../contexts/state';
import Chapters from './chapters';
import LLM from './llm';
export function useChapterSummarization() {
const state = useAppState();
const stateRef = useRef<AppState>(state);
stateRef.current = state;
const [isSummarizing, setIsSummarizing] = useState(false);
const summarizeAll = async () => {
const { currentStory, connection, model, dispatch } = stateRef.current;
if (!currentStory || !connection || !model || isSummarizing) return;
setIsSummarizing(true);
try {
const parsed = Chapters.parseText(currentStory.text);
const validHashes: Record<string, Chapters.Hash[]> = {};
for (const parsedChapter of parsed) {
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
const cachedChapter = (currentStory.chapters ?? [])
.find(c => c.header === parsedChapter.header)
?? Chapters.emptyChapter(parsedChapter.header);
validHashes[parsedChapter.header] = [];
for (const body of chunks) {
const { hash, summary } = Chapters.lookupSummary(cachedChapter, body);
if (summary === null) {
const newSummary = await LLM.summarize(connection, model.id, body);
dispatch({
type: 'STORE_CHAPTER_SUMMARY',
storyId: currentStory.id,
header: parsedChapter.header,
hash,
summary: newSummary,
});
}
validHashes[parsedChapter.header].push(hash);
}
}
// Clean up stale cache entries
dispatch({
type: 'CLEAN_CHAPTER_SUMMARIES',
storyId: currentStory.id,
validHashes,
});
} finally {
setIsSummarizing(false);
}
};
return { summarizeAll, isSummarizing };
}