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 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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 { 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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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