1
0
Fork 0

Add continue button, enforce editing tool rules

This commit is contained in:
Pabloader 2026-03-26 14:53:37 +00:00
parent 5707bfef5e
commit f5479690f9
5 changed files with 171 additions and 30 deletions

View File

@ -73,7 +73,6 @@
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
border-left: 2px solid currentColor; border-left: 2px solid currentColor;
margin-bottom: .5em;
padding-left: .5em; padding-left: .5em;
} }

View File

@ -198,21 +198,51 @@
} }
} }
.buttonRow {
display: flex;
gap: 6px;
}
.sendButton { .sendButton {
flex: 1;
padding: 8px 16px; padding: 8px 16px;
font-size: 13px; font-size: 14px;
font-weight: bold; font-weight: bold;
color: var(--bg); color: var(--bg);
background: var(--accent); background: var(--accent);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
height: 32px;
&:hover { &:hover {
background: var(--accent-alt); background: var(--accent-alt);
} }
} }
.continueButton {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
height: 32px;
&:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-muted);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.stopButton { .stopButton {
padding: 8px 16px; padding: 8px 16px;
font-size: 13px; font-size: 13px;
@ -222,6 +252,7 @@
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
height: 32px;
&:hover { &:hover {
background: #d32f2f; background: #d32f2f;

View File

@ -8,11 +8,11 @@ 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 { Sparkles, ChevronsRight } from "lucide-preact";
import clsx from "clsx"; import clsx from "clsx";
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
// ─── Role Header ────────────────────────────────────────────────────────────── const CONTINUE_PROMPT = "Continue the story forward.\nUse `edit_text` tool in append mode to add new text to the story.";
interface RoleHeaderProps { interface RoleHeaderProps {
message: ChatMessage; message: ChatMessage;
@ -57,6 +57,8 @@ export const ChatSidebar = () => {
const appStateRef = useRef(appState); const appStateRef = useRef(appState);
appStateRef.current = appState; appStateRef.current = appState;
const lastMessage = currentStory?.chatMessages.at(-1);
useEffect(() => { useEffect(() => {
if (messagesRef.current) { if (messagesRef.current) {
messagesRef.current.scrollTo({ messagesRef.current.scrollTo({
@ -66,7 +68,8 @@ export const ChatSidebar = () => {
} }
}, [ }, [
currentStory?.chatMessages.length, currentStory?.chatMessages.length,
currentStory?.chatMessages.at(-1)?.content.length, lastMessage?.content.length,
lastMessage?.role === 'assistant' && lastMessage?.reasoning_content?.length,
]); ]);
useEffect(() => { useEffect(() => {
@ -202,6 +205,9 @@ export const ChatSidebar = () => {
if (tool_calls) { if (tool_calls) {
const toolMessages: ChatMessage[] = []; const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) { for (const tool of tool_calls) {
if (abortControllerRef.current?.signal.aborted) {
break;
}
const content = await Tools.executeTool(appStateRef.current, tool); const content = await Tools.executeTool(appStateRef.current, tool);
const message: ChatMessage = { const message: ChatMessage = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -217,7 +223,9 @@ export const ChatSidebar = () => {
toolMessages.push(message); toolMessages.push(message);
} }
return sendMessage([...newMessages, assistantMessage, ...toolMessages]); if (!abortControllerRef.current?.signal.aborted) {
return sendMessage([...newMessages, assistantMessage, ...toolMessages]);
}
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error const errorMessage = err instanceof Error
@ -231,11 +239,24 @@ export const ChatSidebar = () => {
const handleSendMessage = useCallback(async () => { const handleSendMessage = useCallback(async () => {
if (!currentStory || !input.trim() || !connection || !model || isLoading) return; if (!currentStory || !input.trim() || !connection || !model || isLoading) return;
const userMessage = { setInput('');
id: crypto.randomUUID(), setIsLoading(true);
role: 'user' as const, setError(null);
content: input.trim(), abortControllerRef.current = new AbortController();
};
try {
await sendMessage([{
id: crypto.randomUUID(),
role: 'user' as const,
content: input.trim(),
}]);
} finally {
setIsLoading(false);
}
}, [currentStory, input, connection, model, isLoading, sendMessage]);
const handleContinue = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
setInput(''); setInput('');
setIsLoading(true); setIsLoading(true);
@ -243,7 +264,11 @@ export const ChatSidebar = () => {
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
try { try {
await sendMessage([userMessage]); await sendMessage([{
id: crypto.randomUUID(),
role: 'user' as const,
content: (CONTINUE_PROMPT + '\n\n' + input).trim(),
}]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -373,13 +398,23 @@ export const ChatSidebar = () => {
Stop Stop
</button> </button>
) : ( ) : (
<button <div class={styles.buttonRow}>
class={styles.sendButton} <button
onClick={handleSendMessage} class={styles.sendButton}
disabled={isDisabled || !input.trim()} onClick={handleSendMessage}
> disabled={isDisabled || !input.trim()}
Send >
</button> Send
</button>
<button
class={styles.continueButton}
onClick={handleContinue}
disabled={isDisabled}
title="Continue"
>
<ChevronsRight size={14} />
</button>
</div>
)} )}
</div> </div>
)} )}

View File

@ -46,12 +46,6 @@ export const Editor = () => {
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [currentStory?.id, currentStory?.currentTab]);
const storyValue = useMemo(() => { const storyValue = useMemo(() => {
if (!currentStory) return ''; if (!currentStory) return '';
@ -62,16 +56,43 @@ export const Editor = () => {
if (idx === -1) return highlight(text); if (idx === -1) return highlight(text);
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length); const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
return highlight(marked); return highlight(marked);
}, [currentStory?.text, currentStory?.lastEditedText]); }, [currentStory?.text, currentStory?.lastEditedText]);
const scratchpadValue = useMemo(() => {
return highlight(currentStory?.scratchpad || '');
}, [currentStory?.scratchpad]);
const promptPreview = useMemo(() => { const promptPreview = useMemo(() => {
if (currentStory?.currentTab !== 'prompt') return ''; if (currentStory?.currentTab !== 'prompt') return '';
const text = Prompt.formatSystemPrompt(appState); const text = Prompt.formatSystemPrompt(appState);
return highlight(text, false); return highlight(text, false);
}, [currentStory?.currentTab, appState]); }, [currentStory?.currentTab, appState]);
useEffect(() => {
if (currentStory?.lastEditedText) {
const raf = requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.scrollTo({
top: contentRef.current.scrollHeight,
behavior: 'smooth',
});
}
});
return () => cancelAnimationFrame(raf);
}
}, [currentStory?.lastEditedText]);
useEffect(() => {
const raf = requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
});
return () => cancelAnimationFrame(raf);
}, [currentStory?.id, currentStory?.currentTab]);
if (!currentStory) { if (!currentStory) {
return <div class={styles.editor} />; return <div class={styles.editor} />;
} }
@ -105,7 +126,7 @@ export const Editor = () => {
{currentStory.currentTab === "scratchpad" && ( {currentStory.currentTab === "scratchpad" && (
<ContentEditable <ContentEditable
class={styles.editable} class={styles.editable}
value={currentStory.scratchpad ?? ''} value={scratchpadValue}
onInput={handleScratchpadInput} onInput={handleScratchpadInput}
placeholder="Notes, ideas, outlines — anything you don't want in the story..." placeholder="Notes, ideas, outlines — anything you don't want in the story..."
/> />

View File

@ -5,6 +5,7 @@ import type LLM from "./llm";
const VALID_SCALES = Object.values(LocationScale); const VALID_SCALES = Object.values(LocationScale);
const VALID_ROLES = Object.values(CharacterRole); const VALID_ROLES = Object.values(CharacterRole);
const LINES_LIMIT = 7;
export namespace Tools { export namespace Tools {
interface Tool<T extends TObject = TObject> { interface Tool<T extends TObject = TObject> {
@ -333,7 +334,6 @@ export namespace Tools {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
const target = args.target ?? 'story'; const target = args.target ?? 'story';
const isScratchpad = target === 'scratchpad'; const isScratchpad = target === 'scratchpad';
const currentText = isScratchpad ? (appState.currentStory.scratchpad ?? '') : appState.currentStory.text; const currentText = isScratchpad ? (appState.currentStory.scratchpad ?? '') : appState.currentStory.text;
@ -347,9 +347,64 @@ export namespace Tools {
// Append mode: when old_text is not provided, append new_text // Append mode: when old_text is not provided, append new_text
if (args.old_text == null) { if (args.old_text == null) {
if (!isScratchpad) {
const isAppendToStory = (tc: LLM.ToolCall) => {
const jsonArgs = JSON.stringify(tc.function.arguments);
return tc.function.name === 'edit_text'
&& (
!jsonArgs.match(/\\?"old_text\\?"/)
|| jsonArgs.match(/\\?"old_text\\?":\\?"\\?"/)
)
&& (
!jsonArgs.match(/\\?"target\\?"/)
|| jsonArgs.match(/\\?"target\\?":\\?"story\\?"/)
);
}
// Check that there was a user message since the last edit_text append
const messages = appState.currentStory.chatMessages;
let hasUserMessageSinceLastAppend = true;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role === 'user') break;
if (m.role === 'assistant' && m.tool_calls?.some(isAppendToStory)) {
hasUserMessageSinceLastAppend = false;
break;
}
}
if (!hasUserMessageSinceLastAppend) {
return `Error: You cannot add new text to the story until user approves your last edit. Stop right there.`;
}
}
const numLines = args.new_text.split('\n').filter(l => l.trim()).length;
let cropped = false;
if (!isScratchpad && numLines > LINES_LIMIT) {
const lines = args.new_text.split('\n');
let kept = 0;
const croppedLines: string[] = [];
for (const line of lines) {
if (line.trim()) {
if (kept >= LINES_LIMIT) break;
kept++;
}
croppedLines.push(line);
}
args.new_text = croppedLines.join('\n');
cropped = true;
}
dispatchEdit(currentText + '\n' + args.new_text); dispatchEdit(currentText + '\n' + args.new_text);
appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab });
return `Text appended to ${target} successfully`; let message = cropped
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nWarning: The rest was cropped due to ${LINES_LIMIT} lines limit! Write less next time.`
: `Text appended to ${target} successfully.`;
message += `\nNote: you can't continue until user's approval, stop.`
return message;
} }
// Replace mode // Replace mode
@ -365,7 +420,7 @@ export namespace Tools {
appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab });
return `${target.charAt(0).toUpperCase() + target.slice(1)} edited successfully`; return `${target.charAt(0).toUpperCase() + target.slice(1)} edited successfully`;
}, },
description: "Replace or append text in the story or scratchpad. When old_text is omitted, appends new_text to the end. Case-sensitive.", description: `Replace or append text in the story or scratchpad. When old_text is omitted, appends new_text to the end. Case-sensitive. When appending to the main story, you can add no more than ${LINES_LIMIT} non-empty lines at once.`,
parameters: Type.Object({ parameters: Type.Object({
new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }), new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }),
old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })), old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })),