diff --git a/src/common/assets/highlight.module.css b/src/common/assets/highlight.module.css
index 615b8b9..9a2acf4 100644
--- a/src/common/assets/highlight.module.css
+++ b/src/common/assets/highlight.module.css
@@ -73,7 +73,6 @@
white-space: pre-wrap;
word-wrap: break-word;
border-left: 2px solid currentColor;
- margin-bottom: .5em;
padding-left: .5em;
}
diff --git a/src/games/storywriter/assets/chat-sidebar.module.css b/src/games/storywriter/assets/chat-sidebar.module.css
index dc917d7..0018325 100644
--- a/src/games/storywriter/assets/chat-sidebar.module.css
+++ b/src/games/storywriter/assets/chat-sidebar.module.css
@@ -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;
diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx
index bf93998..4374364 100644
--- a/src/games/storywriter/components/chat-sidebar.tsx
+++ b/src/games/storywriter/components/chat-sidebar.tsx
@@ -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
) : (
-
+
+
+
+
)}
)}
diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx
index ce4ecd9..dd0a846 100644
--- a/src/games/storywriter/components/editor.tsx
+++ b/src/games/storywriter/components/editor.tsx
@@ -46,12 +46,6 @@ export const Editor = () => {
const contentRef = useRef(null);
- useEffect(() => {
- if (contentRef.current) {
- contentRef.current.scrollTop = contentRef.current.scrollHeight;
- }
- }, [currentStory?.id, currentStory?.currentTab]);
-
const storyValue = useMemo(() => {
if (!currentStory) return '';
@@ -62,16 +56,43 @@ export const Editor = () => {
if (idx === -1) return highlight(text);
const marked = text.slice(0, idx) + '' + lastEditedText + '' + text.slice(idx + lastEditedText.length);
-
+
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 ;
}
@@ -105,7 +126,7 @@ export const Editor = () => {
{currentStory.currentTab === "scratchpad" && (
diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts
index 6e0cfd9..19aa45d 100644
--- a/src/games/storywriter/utils/tools.ts
+++ b/src/games/storywriter/utils/tools.ts
@@ -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 {
@@ -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' })),