Add continue button, enforce editing tool rules
This commit is contained in:
parent
5707bfef5e
commit
f5479690f9
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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' })),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue