Add continue button, enforce editing tool rules
This commit is contained in:
parent
5707bfef5e
commit
f5479690f9
|
|
@ -73,7 +73,6 @@
|
|||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
border-left: 2px solid currentColor;
|
||||
margin-bottom: .5em;
|
||||
padding-left: .5em;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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' })),
|
||||
|
|
|
|||
Loading…
Reference in New Issue