Include the story in the context
This commit is contained in:
parent
af59e03212
commit
6e61ff7194
|
|
@ -20,7 +20,7 @@
|
|||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 72px;
|
||||
padding: 0 72px 72px;
|
||||
}
|
||||
|
||||
.editable {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue