1
0
Fork 0

Include the story in the context

This commit is contained in:
Pabloader 2026-03-25 09:19:59 +00:00
parent af59e03212
commit 6e61ff7194
4 changed files with 198 additions and 19 deletions

View File

@ -20,7 +20,7 @@
.content {
flex: 1;
overflow-y: auto;
padding: 0 72px;
padding: 0 72px 72px;
}
.editable {

View File

@ -49,6 +49,7 @@ export interface Story {
id: string;
title: string;
text: string;
lastModifiedChunk: string;
lore: string;
characters: Character[];
locations: Location[];
@ -73,7 +74,7 @@ interface IState {
type Action =
| { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string }
| { type: 'EDIT_STORY'; id: string; text: string; lastModifiedChunk?: string }
| { type: 'EDIT_LORE'; id: string; lore: string }
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
@ -116,6 +117,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(),
title: action.title,
text: '',
lastModifiedChunk: '',
lore: '',
characters: [],
locations: [],
@ -140,7 +142,11 @@ function reducer(state: IState, action: Action): IState {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, text: action.text } : s
s.id === action.id ? {
...s,
text: action.text,
lastModifiedChunk: action.lastModifiedChunk ?? s.lastModifiedChunk
} : s
),
};
}

View File

@ -3,6 +3,62 @@ import { type AppState, LocationScale } from "../contexts/state";
import { Tools } from "./tools";
namespace Prompt {
function approxTokens(text: string): number {
return text.length / 3;
}
export function formatStoryText(text: string, tokenBudget: number): string {
if (!text) return '';
if (approxTokens(text) <= tokenBudget / 2) {
return text;
}
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;
if (targetChars <= 0) {
return separator;
}
// 1/3 of budget for start, 2/3 for end
const startCharsMax = Math.floor(targetChars / 3);
const endCharsMax = targetChars - startCharsMax;
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;
}
let endCharsUsed = 0;
let endStart = lines.length;
for (let i = lines.length - 1; i >= startEnd; i--) {
const lineLen = lines[i].length + 1;
if (endCharsUsed + lineLen > endCharsMax) break;
endCharsUsed += lineLen;
endStart = i;
}
if (startEnd >= endStart) {
return text; // All lines fit after all
}
const startPart = lines.slice(0, startEnd).join('\n');
const endPart = lines.slice(endStart).join('\n');
const parts: string[] = [];
if (startPart) parts.push(startPart);
parts.push(separator);
if (endPart) parts.push(endPart);
return parts.join('\n');
}
export function formatCharactersMarkdown(state: AppState): string {
const { currentStory } = state;
if (!currentStory || !currentStory.characters?.length) {
@ -57,7 +113,7 @@ namespace Prompt {
return lines.join('\n');
}
export function formatSystemPrompt(state: AppState): string {
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
const { currentStory } = state;
if (!currentStory) {
return state.systemInstruction;
@ -68,7 +124,7 @@ namespace Prompt {
parts.push(`# ${currentStory.title}`);
if (currentStory.lore) {
parts.push('## Lore\n' + currentStory.lore);
parts.push(`## Lore\n${currentStory.lore}`);
}
const charactersSection = formatCharactersMarkdown(state);
@ -81,6 +137,17 @@ namespace Prompt {
parts.push(locationsSection);
}
if (currentStory.text && storyTokenBudget > 0) {
const storyText = formatStoryText(currentStory.text, storyTokenBudget);
if (storyText) {
parts.push(`## Story\n${storyText}`);
}
}
if (currentStory.lastModifiedChunk) {
parts.push(`## Last Modified Chunk\n${currentStory.lastModifiedChunk}`);
}
return parts.join('\n\n');
}
@ -91,9 +158,20 @@ namespace Prompt {
return null;
}
// Estimate token budget for story text
let storyTokenBudget = 0;
if (model.max_context) {
const nonStorySystem = formatSystemPrompt(state, 0);
const chatText = [...currentStory.chatMessages, ...newMessages]
.map(m => m.content)
.join('');
const maxOutput = model.max_length ?? 2048;
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
storyTokenBudget = model.max_context - otherTokens;
}
const messages: LLM.ChatMessage[] = [
{ role: 'system', content: formatSystemPrompt(state) },
// TODO part of story
{ role: 'system', content: formatSystemPrompt(state, storyTokenBudget) },
...currentStory.chatMessages,
];
@ -105,7 +183,7 @@ namespace Prompt {
tools: Tools.getTools(),
banned_tokens: state.bannedTokens,
enable_thinking: enableThinking,
max_tokens: model.max_length ? model.max_length / 2 : 2048,
max_tokens: model.max_length ? model.max_length : 2048,
};
}
}

View File

@ -28,7 +28,8 @@ export namespace Tools {
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text + args.text
text: appState.currentStory.text + args.text,
lastModifiedChunk: args.text
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
@ -228,20 +229,114 @@ export namespace Tools {
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
})),
}),
}),
'edit_story': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const occurrences = appState.currentStory.text.split(args.old_text).length - 1;
if (occurrences === 0) {
return 'Error: old_text not found in story';
}
if (occurrences > 1 && !args.replace_all) {
return 'Error: old_text appears multiple times in story';
}
appState.dispatch({
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',
id: appState.currentStory.id,
tab: 'story'
});
return 'Story edited successfully';
},
description: 'Replace text in the current story',
parameters: Type.Object({
old_text: Type.String({ description: 'The text to find and replace in the story' }),
new_text: Type.String({ description: 'The new text to replace old_text with' }),
replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })),
}),
}),
'edit_lore': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const occurrences = appState.currentStory.lore.split(args.old_text).length - 1;
if (occurrences === 0) {
return 'Error: old_text not found in lore';
}
if (occurrences > 1 && !args.replace_all) {
return 'Error: old_text appears multiple times in lore';
}
appState.dispatch({
type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore.replaceAll(args.old_text, args.new_text),
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return 'Lore edited successfully';
},
description: 'Replace text in the story lore',
parameters: Type.Object({
old_text: Type.String({ description: 'The text to find and replace in the lore' }),
new_text: Type.String({ description: 'The new text to replace old_text with' }),
replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })),
}),
}),
'grep': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const lines = appState.currentStory.text.split('\n');
const matches: { line: number; content: string }[] = [];
const pattern = new RegExp(args.pattern, args.case_sensitive ? 'g' : 'gi');
for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i])) {
matches.push({ line: i + 1, content: lines[i].trim() });
}
}
if (matches.length === 0) {
return `No matches found for pattern: ${args.pattern}`;
}
const limit = args.limit ?? 20;
const truncated = matches.length > limit;
const displayed = truncated ? matches.slice(0, limit) : matches;
let result = `Found ${matches.length} match(es) for pattern "${args.pattern}":\n`;
result += displayed.map(m => `Line ${m.line}: ${m.content}`).join('\n');
if (truncated) {
result += `\n... and ${matches.length - limit} more match(es)`;
}
return result;
},
description: 'Search for a pattern in the story text',
parameters: Type.Object({
pattern: Type.String({ description: 'The regex pattern to search for' }),
case_sensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-sensitive (default: false)' })),
limit: Type.Optional(Type.Integer({ description: 'Maximum number of matches to return (default: 20)' })),
}),
})
};
export function getTools(): LLM.Tool[] {
return Object.entries(TOOLS).map(([key, tool]) => {
return {
type: 'function',
function: {
name: key,
description: tool.description,
parameters: tool.parameters,
},
};
});
return Object.entries(TOOLS).map(([key, tool]) => ({
type: 'function',
function: {
name: key,
description: tool.description,
parameters: tool.parameters,
},
}));
}
function parseArg(arg: unknown): unknown {