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' })),