1
0
Fork 0
This commit is contained in:
Pabloader 2026-03-25 12:27:06 +00:00
parent bbe5716e40
commit 5a6897f1f6
11 changed files with 487 additions and 37 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

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

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

@ -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 <div class={styles.editor} />;
@ -51,7 +57,12 @@ export const Editor = () => {
return (
<div class={styles.editor}>
<div class={styles.title}>{currentStory.title}</div>
<div class={styles.title}>
{currentStory.title}
{pendingCount > 0 && (
<span class={styles.summarizing}>Summarizing ({pendingCount})</span>
)}
</div>
<div class={styles.content}>
{currentStory.currentTab === "story" && (
<ContentEditable
@ -75,17 +86,29 @@ 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}
</button>
))}
<button
class={clsx(styles.tab, styles.tabRight, summarizationPaused && styles.active)}
onClick={() => dispatch({ type: 'SET_SUMMARIZATION_PAUSED', paused: !summarizationPaused })}
>
{summarizationPaused
? <><Play size={14} /> Summarization paused</>
: <><Pause size={14} /> Pause summarization</>
}
</button>
</div>
</div>
);

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 ───────────────────────────────────────────────────────────────────
@ -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<Location> }
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string };
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string }
| { type: 'SET_SUMMARIZATION_PAUSED'; paused: boolean }
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string }
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
// ─── Initial State ───────────────────────────────────────────────────────────
@ -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]);

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

@ -144,10 +144,6 @@ namespace Prompt {
}
}
if (currentStory.lastModifiedChunk) {
parts.push(`## Last Modified Chunk\n${currentStory.lastModifiedChunk}`);
}
return parts.join('\n\n');
}

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,123 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useAppState, type AppState } from '../contexts/state';
import Chapters from './chapters';
import LLM from './llm';
interface SummarizationJob {
storyId: string;
header: string;
index: number;
body: string;
hash: Chapters.Hash;
}
const DEBOUNCE_MS = 2000;
export function useChapterSummarization() {
const state = useAppState();
// Always-fresh ref so async processQueue reads current connection/model/dispatch
const stateRef = useRef<AppState>(state);
stateRef.current = state;
const queueRef = useRef<SummarizationJob[]>([]);
const processingRef = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const processQueue = async () => {
if (processingRef.current) return;
processingRef.current = true;
try {
while (queueRef.current.length > 0) {
const { connection, model, dispatch, summarizationPaused } = stateRef.current;
if (!connection || !model || summarizationPaused) break;
const job = queueRef.current[0];
setPendingCount(queueRef.current.length);
queueRef.current = queueRef.current.slice(1);
try {
const summary = await LLM.summarize(connection, model.id, job.body);
dispatch({
type: 'STORE_CHAPTER_SUMMARY',
storyId: job.storyId,
header: job.header,
hash: job.hash,
summary,
});
} catch {
// skip failed job, continue with rest
}
}
} finally {
processingRef.current = false;
setPendingCount(0);
}
};
const enqueue = (jobs: SummarizationJob[]) => {
for (const job of jobs) {
const idx = queueRef.current.findIndex(
j => j.header === job.header && j.index === job.index
);
if (idx !== -1) {
queueRef.current[idx] = job;
} else {
queueRef.current.push(job);
}
}
setPendingCount(queueRef.current.length);
processQueue();
};
// Re-scan when text changes (debounced)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const { currentStory } = stateRef.current;
if (!currentStory?.text) return;
const parsed = Chapters.parseText(currentStory.text);
const jobs: SummarizationJob[] = [];
const validHashes: Record<string, Chapters.Hash[]> = {};
for (const parsedChapter of parsed) {
const chunks = Chapters.splitIntoChunks(parsedChapter.body);
const cachedChapter = (currentStory.chapters ?? [])
.find(c => c.header === parsedChapter.header)
?? Chapters.emptyChapter(parsedChapter.header);
validHashes[parsedChapter.header] = [];
for (let i = 0; i < chunks.length; i++) {
const { hash, summary } = Chapters.lookupSummary(cachedChapter, chunks[i]);
validHashes[parsedChapter.header].push(hash);
if (summary === null) {
jobs.push({ storyId: currentStory.id, header: parsedChapter.header, index: i, body: chunks[i], hash });
}
}
}
stateRef.current.dispatch({ type: 'CLEAN_CHAPTER_SUMMARIES', storyId: currentStory.id, validHashes });
if (jobs.length > 0) {
enqueue(jobs);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [state.currentStory?.text]);
// Resume processing if connection/model become available or summarization is unpaused
useEffect(() => {
if (queueRef.current.length > 0) {
processQueue();
}
}, [state.connection, state.model, state.summarizationPaused]);
return { pendingCount };
}