Summarization
This commit is contained in:
parent
5a6897f1f6
commit
a605c95890
|
|
@ -133,10 +133,35 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,6 @@
|
||||||
text-align: center;
|
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 {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ 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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -41,6 +43,7 @@ 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);
|
||||||
|
|
@ -336,11 +339,16 @@ export const ChatSidebar = () => {
|
||||||
/>
|
/>
|
||||||
<span>Enable thinking</span>
|
<span>Enable thinking</span>
|
||||||
</label>
|
</label>
|
||||||
{tokenCount && (
|
<div class={styles.tokenCounter}>
|
||||||
<div class={styles.tokenCounter}>
|
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
||||||
{tokenCount.taken} / {tokenCount.total} tokens
|
<button
|
||||||
</div>
|
class={styles.summarizeButton}
|
||||||
)}
|
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}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
import { highlight } from "@common/highlight";
|
import { highlight } from "@common/highlight";
|
||||||
import { useAppState, type Tab } from "../contexts/state";
|
import { useAppState, type Tab } from "../contexts/state";
|
||||||
import { useChapterSummarization } from "../utils/useChapterSummarization";
|
|
||||||
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 clsx from "clsx";
|
||||||
import { Pause, Play } from "lucide-preact";
|
|
||||||
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";
|
import { ChaptersEditor } from "./chapters-editor";
|
||||||
|
|
@ -19,8 +17,7 @@ const TABS: { id: Tab; label: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Editor = () => {
|
export const Editor = () => {
|
||||||
const { currentStory, summarizationPaused, dispatch } = useAppState();
|
const { currentStory, dispatch } = useAppState();
|
||||||
const { pendingCount } = useChapterSummarization();
|
|
||||||
|
|
||||||
if (!currentStory) {
|
if (!currentStory) {
|
||||||
return <div class={styles.editor} />;
|
return <div class={styles.editor} />;
|
||||||
|
|
@ -59,9 +56,6 @@ export const Editor = () => {
|
||||||
<div class={styles.editor}>
|
<div class={styles.editor}>
|
||||||
<div class={styles.title}>
|
<div class={styles.title}>
|
||||||
{currentStory.title}
|
{currentStory.title}
|
||||||
{pendingCount > 0 && (
|
|
||||||
<span class={styles.summarizing}>Summarizing ({pendingCount})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
{currentStory.currentTab === "story" && (
|
{currentStory.currentTab === "story" && (
|
||||||
|
|
@ -100,15 +94,6 @@ export const Editor = () => {
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ interface IState {
|
||||||
enableThinking: boolean;
|
enableThinking: boolean;
|
||||||
bannedTokens: string[];
|
bannedTokens: string[];
|
||||||
systemInstruction: string;
|
systemInstruction: string;
|
||||||
summarizationPaused: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -97,7 +96,6 @@ type Action =
|
||||||
| { 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: 'SET_SUMMARIZATION_PAUSED'; paused: boolean }
|
|
||||||
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: 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[]> };
|
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
|
||||||
|
|
||||||
|
|
@ -111,7 +109,6 @@ const DEFAULT_STATE: IState = {
|
||||||
enableThinking: false,
|
enableThinking: false,
|
||||||
bannedTokens: [],
|
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.`,
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -388,9 +385,6 @@ function reducer(state: IState, action: Action): IState {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'SET_SUMMARIZATION_PAUSED': {
|
|
||||||
return { ...state, summarizationPaused: action.paused };
|
|
||||||
}
|
|
||||||
case 'STORE_CHAPTER_SUMMARY': {
|
case 'STORE_CHAPTER_SUMMARY': {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -423,7 +417,6 @@ export interface AppState {
|
||||||
enableThinking: boolean;
|
enableThinking: boolean;
|
||||||
bannedTokens: string[];
|
bannedTokens: string[];
|
||||||
systemInstruction: string;
|
systemInstruction: string;
|
||||||
summarizationPaused: boolean;
|
|
||||||
dispatch: (action: Action) => void;
|
dispatch: (action: Action) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +437,6 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
enableThinking: state.enableThinking,
|
enableThinking: state.enableThinking,
|
||||||
bannedTokens: state.bannedTokens ?? [],
|
bannedTokens: state.bannedTokens ?? [],
|
||||||
systemInstruction: state.systemInstruction ?? '',
|
systemInstruction: state.systemInstruction ?? '',
|
||||||
summarizationPaused: state.summarizationPaused ?? false,
|
|
||||||
dispatch,
|
dispatch,
|
||||||
}), [state]);
|
}), [state]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -7,56 +8,106 @@ namespace Prompt {
|
||||||
return text.length / 3;
|
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 (!text) return '';
|
||||||
|
|
||||||
if (approxTokens(text) <= tokenBudget / 2) {
|
const slots = buildSlots(text, chapters);
|
||||||
return text;
|
if (slots.length === 0) return '';
|
||||||
|
|
||||||
|
if (countSlotTokens(slots) <= tokenBudget) {
|
||||||
|
return renderSlots(slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = text.split('\n');
|
const recentStart = Math.max(0, slots.length - KEEP_RECENT_CHUNKS);
|
||||||
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;
|
|
||||||
|
|
||||||
if (targetChars <= 0) {
|
// Phase 1: summarize non-recent chunks, stop as soon as we fit
|
||||||
return separator;
|
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
|
// Phase 2: delete from middle outward, never delete last slot
|
||||||
const startCharsMax = Math.floor(targetChars / 3);
|
const middle = recentStart / 2;
|
||||||
const endCharsMax = targetChars - startCharsMax;
|
const deletable = Array.from({ length: recentStart }, (_, i) => i)
|
||||||
|
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
|
||||||
|
|
||||||
let startCharsUsed = 0;
|
for (const i of deletable) {
|
||||||
let startEnd = 0;
|
slots[i].mode = 'omitted';
|
||||||
for (let i = 0; i < lines.length; i++) {
|
if (countSlotTokens(slots) <= tokenBudget) break;
|
||||||
const lineLen = lines[i].length + 1; // +1 for '\n'
|
|
||||||
if (startCharsUsed + lineLen > startCharsMax) break;
|
|
||||||
startCharsUsed += lineLen;
|
|
||||||
startEnd = i + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let endCharsUsed = 0;
|
return renderSlots(slots);
|
||||||
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 {
|
||||||
|
|
@ -138,7 +189,7 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStory.text && storyTokenBudget > 0) {
|
if (currentStory.text && storyTokenBudget > 0) {
|
||||||
const storyText = formatStoryText(currentStory.text, storyTokenBudget);
|
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
|
||||||
if (storyText) {
|
if (storyText) {
|
||||||
parts.push(`## Story\n${storyText}`);
|
parts.push(`## Story\n${storyText}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,22 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useRef, useState } from 'preact/hooks';
|
||||||
import { useAppState, type AppState } from '../contexts/state';
|
import { useAppState, type AppState } from '../contexts/state';
|
||||||
import Chapters from './chapters';
|
import Chapters from './chapters';
|
||||||
import LLM from './llm';
|
import LLM from './llm';
|
||||||
|
|
||||||
interface SummarizationJob {
|
|
||||||
storyId: string;
|
|
||||||
header: string;
|
|
||||||
index: number;
|
|
||||||
body: string;
|
|
||||||
hash: Chapters.Hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
|
||||||
|
|
||||||
export function useChapterSummarization() {
|
export function useChapterSummarization() {
|
||||||
const state = useAppState();
|
const state = useAppState();
|
||||||
|
|
||||||
// Always-fresh ref so async processQueue reads current connection/model/dispatch
|
|
||||||
const stateRef = useRef<AppState>(state);
|
const stateRef = useRef<AppState>(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
|
|
||||||
const queueRef = useRef<SummarizationJob[]>([]);
|
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||||
const processingRef = useRef(false);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
|
||||||
|
|
||||||
const processQueue = async () => {
|
const summarizeAll = async () => {
|
||||||
if (processingRef.current) return;
|
const { currentStory, connection, model, dispatch } = stateRef.current;
|
||||||
processingRef.current = true;
|
if (!currentStory || !connection || !model || isSummarizing) return;
|
||||||
|
|
||||||
|
setIsSummarizing(true);
|
||||||
try {
|
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 parsed = Chapters.parseText(currentStory.text);
|
||||||
const jobs: SummarizationJob[] = [];
|
|
||||||
const validHashes: Record<string, Chapters.Hash[]> = {};
|
const validHashes: Record<string, Chapters.Hash[]> = {};
|
||||||
|
|
||||||
for (const parsedChapter of parsed) {
|
for (const parsedChapter of parsed) {
|
||||||
|
|
@ -92,32 +27,34 @@ export function useChapterSummarization() {
|
||||||
|
|
||||||
validHashes[parsedChapter.header] = [];
|
validHashes[parsedChapter.header] = [];
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (const body of chunks) {
|
||||||
const { hash, summary } = Chapters.lookupSummary(cachedChapter, chunks[i]);
|
const { hash, summary } = Chapters.lookupSummary(cachedChapter, body);
|
||||||
validHashes[parsedChapter.header].push(hash);
|
|
||||||
if (summary === null) {
|
if (summary === null) {
|
||||||
jobs.push({ storyId: currentStory.id, header: parsedChapter.header, index: i, body: chunks[i], hash });
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stateRef.current.dispatch({ type: 'CLEAN_CHAPTER_SUMMARIES', storyId: currentStory.id, validHashes });
|
// Clean up stale cache entries
|
||||||
if (jobs.length > 0) {
|
dispatch({
|
||||||
enqueue(jobs);
|
type: 'CLEAN_CHAPTER_SUMMARIES',
|
||||||
}
|
storyId: currentStory.id,
|
||||||
}, DEBOUNCE_MS);
|
validHashes,
|
||||||
|
});
|
||||||
return () => {
|
} finally {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
setIsSummarizing(false);
|
||||||
};
|
|
||||||
}, [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 };
|
return { summarizeAll, isSummarizing };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue