Include the story in the context
This commit is contained in:
parent
af59e03212
commit
6e61ff7194
|
|
@ -20,7 +20,7 @@
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 72px;
|
padding: 0 72px 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable {
|
.editable {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export interface Story {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
lastModifiedChunk: string;
|
||||||
lore: string;
|
lore: string;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
locations: Location[];
|
locations: Location[];
|
||||||
|
|
@ -73,7 +74,7 @@ interface IState {
|
||||||
type Action =
|
type Action =
|
||||||
| { type: 'CREATE_STORY'; title: string }
|
| { type: 'CREATE_STORY'; title: string }
|
||||||
| { type: 'RENAME_STORY'; id: string; 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: 'EDIT_LORE'; id: string; lore: string }
|
||||||
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
|
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
|
||||||
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
|
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
|
||||||
|
|
@ -116,6 +117,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: action.title,
|
title: action.title,
|
||||||
text: '',
|
text: '',
|
||||||
|
lastModifiedChunk: '',
|
||||||
lore: '',
|
lore: '',
|
||||||
characters: [],
|
characters: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
|
|
@ -140,7 +142,11 @@ function reducer(state: IState, action: Action): IState {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
stories: state.stories.map(s =>
|
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";
|
import { Tools } from "./tools";
|
||||||
|
|
||||||
namespace Prompt {
|
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 {
|
export function formatCharactersMarkdown(state: AppState): string {
|
||||||
const { currentStory } = state;
|
const { currentStory } = state;
|
||||||
if (!currentStory || !currentStory.characters?.length) {
|
if (!currentStory || !currentStory.characters?.length) {
|
||||||
|
|
@ -57,7 +113,7 @@ namespace Prompt {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSystemPrompt(state: AppState): string {
|
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
||||||
const { currentStory } = state;
|
const { currentStory } = state;
|
||||||
if (!currentStory) {
|
if (!currentStory) {
|
||||||
return state.systemInstruction;
|
return state.systemInstruction;
|
||||||
|
|
@ -68,7 +124,7 @@ namespace Prompt {
|
||||||
parts.push(`# ${currentStory.title}`);
|
parts.push(`# ${currentStory.title}`);
|
||||||
|
|
||||||
if (currentStory.lore) {
|
if (currentStory.lore) {
|
||||||
parts.push('## Lore\n' + currentStory.lore);
|
parts.push(`## Lore\n${currentStory.lore}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const charactersSection = formatCharactersMarkdown(state);
|
const charactersSection = formatCharactersMarkdown(state);
|
||||||
|
|
@ -81,6 +137,17 @@ namespace Prompt {
|
||||||
parts.push(locationsSection);
|
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');
|
return parts.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,9 +158,20 @@ namespace Prompt {
|
||||||
return null;
|
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[] = [
|
const messages: LLM.ChatMessage[] = [
|
||||||
{ role: 'system', content: formatSystemPrompt(state) },
|
{ role: 'system', content: formatSystemPrompt(state, storyTokenBudget) },
|
||||||
// TODO part of story
|
|
||||||
...currentStory.chatMessages,
|
...currentStory.chatMessages,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -105,7 +183,7 @@ namespace Prompt {
|
||||||
tools: Tools.getTools(),
|
tools: Tools.getTools(),
|
||||||
banned_tokens: state.bannedTokens,
|
banned_tokens: state.bannedTokens,
|
||||||
enable_thinking: enableThinking,
|
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({
|
appState.dispatch({
|
||||||
type: 'EDIT_STORY',
|
type: 'EDIT_STORY',
|
||||||
id: appState.currentStory.id,
|
id: appState.currentStory.id,
|
||||||
text: appState.currentStory.text + args.text
|
text: appState.currentStory.text + args.text,
|
||||||
|
lastModifiedChunk: args.text
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'SET_CURRENT_TAB',
|
type: 'SET_CURRENT_TAB',
|
||||||
|
|
@ -228,20 +229,114 @@ export namespace Tools {
|
||||||
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
|
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[] {
|
export function getTools(): LLM.Tool[] {
|
||||||
return Object.entries(TOOLS).map(([key, tool]) => {
|
return Object.entries(TOOLS).map(([key, tool]) => ({
|
||||||
return {
|
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: key,
|
name: key,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: tool.parameters,
|
parameters: tool.parameters,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArg(arg: unknown): unknown {
|
function parseArg(arg: unknown): unknown {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue