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;
word-wrap: break-word;
border-left: 2px solid currentColor;
margin-bottom: .5em;
padding-left: .5em;
}

View File

@ -198,21 +198,51 @@
}
}
.buttonRow {
display: flex;
gap: 6px;
}
.sendButton {
flex: 1;
padding: 8px 16px;
font-size: 13px;
font-size: 14px;
font-weight: bold;
color: var(--bg);
background: var(--accent);
border: none;
border-radius: 4px;
cursor: pointer;
height: 32px;
&:hover {
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 {
padding: 8px 16px;
font-size: 13px;
@ -222,6 +252,7 @@
border: none;
border-radius: 4px;
cursor: pointer;
height: 32px;
&:hover {
background: #d32f2f;

View File

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

View File

@ -46,12 +46,6 @@ export const Editor = () => {
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [currentStory?.id, currentStory?.currentTab]);
const storyValue = useMemo(() => {
if (!currentStory) return '';
@ -66,12 +60,39 @@ export const Editor = () => {
return highlight(marked);
}, [currentStory?.text, currentStory?.lastEditedText]);
const scratchpadValue = useMemo(() => {
return highlight(currentStory?.scratchpad || '');
}, [currentStory?.scratchpad]);
const promptPreview = useMemo(() => {
if (currentStory?.currentTab !== 'prompt') return '';
const text = Prompt.formatSystemPrompt(appState);
return highlight(text, false);
}, [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) {
return <div class={styles.editor} />;
}
@ -105,7 +126,7 @@ export const Editor = () => {
{currentStory.currentTab === "scratchpad" && (
<ContentEditable
class={styles.editable}
value={currentStory.scratchpad ?? ''}
value={scratchpadValue}
onInput={handleScratchpadInput}
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_ROLES = Object.values(CharacterRole);
const LINES_LIMIT = 7;
export namespace Tools {
interface Tool<T extends TObject = TObject> {
@ -333,7 +334,6 @@ export namespace Tools {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const target = args.target ?? 'story';
const isScratchpad = target === 'scratchpad';
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
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);
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
@ -365,7 +420,7 @@ export namespace Tools {
appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab });
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({
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' })),