diff --git a/src/common/assets/content-editable.module.css b/src/common/assets/content-editable.module.css new file mode 100644 index 0000000..8eca9b9 --- /dev/null +++ b/src/common/assets/content-editable.module.css @@ -0,0 +1,10 @@ +.autoLines { + overflow: hidden; +} + +.root:empty::before { + content: attr(data-placeholder); + color: var(--text-muted); + font-style: italic; + pointer-events: none; +} diff --git a/src/common/components/ContentEditable.tsx b/src/common/components/ContentEditable.tsx index 8a79cc4..a52a6c8 100644 --- a/src/common/components/ContentEditable.tsx +++ b/src/common/components/ContentEditable.tsx @@ -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, 'value' | 'onInput'> & { value: string; + placeholder?: string; + autoLines?: boolean; onInput?: JSX.EventHandler>; }; @@ -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(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 = (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> = (e) => { + if (autoLines && ref.current) resizeToContent(ref.current); + onInput?.(e); + }; + return (
); diff --git a/src/games/storywriter/assets/chapters-editor.module.css b/src/games/storywriter/assets/chapters-editor.module.css new file mode 100644 index 0000000..73f5a40 --- /dev/null +++ b/src/games/storywriter/assets/chapters-editor.module.css @@ -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); + } + + +} diff --git a/src/games/storywriter/assets/editor.module.css b/src/games/storywriter/assets/editor.module.css index e462bcd..421ecc5 100644 --- a/src/games/storywriter/assets/editor.module.css +++ b/src/games/storywriter/assets/editor.module.css @@ -17,6 +17,16 @@ 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 { flex: 1; overflow-y: auto; @@ -65,7 +75,11 @@ display: flex; border-top: 1px solid var(--border); padding: 0 12px; - gap: 8px; + gap: 6px; +} + +.tabRight { + margin-left: auto; } .tab { diff --git a/src/games/storywriter/components/chapters-editor.tsx b/src/games/storywriter/components/chapters-editor.tsx new file mode 100644 index 0000000..73e7cb3 --- /dev/null +++ b/src/games/storywriter/components/chapters-editor.tsx @@ -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 ( +
+

No chapters yet. Use # Chapter Title headers in your story to create chapters.

+
+ ); + } + + return ( +
+ {parsed.map((parsedChapter) => { + const chunks = Chapters.splitIntoChunks(parsedChapter.body); + const cachedChapter = (currentStory.chapters ?? []).find(c => c.header === parsedChapter.header) + ?? Chapters.emptyChapter(parsedChapter.header); + + return ( +
+
+ {parsedChapter.header ? parsedChapter.header.replace(/^# /, '') : '(Preamble)'} +
+ {chunks.map((body, i) => { + const { hash, summary } = Chapters.lookupSummary(cachedChapter, body); + return ( +
+ {chunks.length > 1 && ( +
Part {i + 1}
+ )} +
{body}
+ dispatch({ + type: 'STORE_CHAPTER_SUMMARY', + storyId: currentStory.id, + header: parsedChapter.header, + hash, + summary: (e.target as HTMLDivElement).textContent || '', + })} + /> +
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 5f8b583..9173374 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -1,20 +1,26 @@ import { ContentEditable } from "@common/components/ContentEditable"; import { highlight } from "@common/highlight"; import { useAppState, type Tab } from "../contexts/state"; +import { useChapterSummarization } from "../utils/useChapterSummarization"; import styles from '../assets/editor.module.css'; import { useMemo } from "preact/hooks"; +import clsx from "clsx"; +import { Pause, Play } from "lucide-preact"; 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" }, ]; export const Editor = () => { - const { currentStory, dispatch } = useAppState(); + const { currentStory, summarizationPaused, dispatch } = useAppState(); + const { pendingCount } = useChapterSummarization(); if (!currentStory) { return
; @@ -51,7 +57,12 @@ export const Editor = () => { return (
-
{currentStory.title}
+
+ {currentStory.title} + {pendingCount > 0 && ( + Summarizing ({pendingCount}) + )} +
{currentStory.currentTab === "story" && ( { {currentStory.currentTab === "locations" && ( )} + {currentStory.currentTab === "chapters" && ( + + )}
{TABS.map((tab) => ( ))} +
); diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 68e2327..8dc6add 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -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 ─────────────────────────────────────────────────────────────────── @@ -67,6 +68,7 @@ interface IState { enableThinking: boolean; bannedTokens: string[]; systemInstruction: string; + summarizationPaused: boolean; } // ─── Actions ───────────────────────────────────────────────────────────────── @@ -74,7 +76,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 +96,10 @@ 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 } - | { 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 }; // ─── Initial State ─────────────────────────────────────────────────────────── @@ -106,6 +111,7 @@ const DEFAULT_STATE: IState = { enableThinking: false, 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.`, + summarizationPaused: false, }; // ─── Reducer ───────────────────────────────────────────────────────────────── @@ -117,12 +123,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 +148,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 +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; bannedTokens: string[]; systemInstruction: string; + summarizationPaused: boolean; dispatch: (action: Action) => void; } @@ -401,6 +444,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => { enableThinking: state.enableThinking, bannedTokens: state.bannedTokens ?? [], systemInstruction: state.systemInstruction ?? '', + summarizationPaused: state.summarizationPaused ?? false, dispatch, }), [state]); diff --git a/src/games/storywriter/utils/chapters.ts b/src/games/storywriter/utils/chapters.ts new file mode 100644 index 0000000..f5c0ea5 --- /dev/null +++ b/src/games/storywriter/utils/chapters.ts @@ -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; + } + + /** 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; diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index d184deb..04445b0 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -144,11 +144,7 @@ namespace Prompt { } } - 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 { diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 98b5a5c..b4161c3 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -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', diff --git a/src/games/storywriter/utils/useChapterSummarization.ts b/src/games/storywriter/utils/useChapterSummarization.ts new file mode 100644 index 0000000..ae24270 --- /dev/null +++ b/src/games/storywriter/utils/useChapterSummarization.ts @@ -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(state); + stateRef.current = state; + + const queueRef = useRef([]); + const processingRef = useRef(false); + const debounceRef = useRef | 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 = {}; + + 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 }; +}