1
0
Fork 0

Compare commits

..

3 Commits

Author SHA1 Message Date
Pabloader c36e5654ed Add scratchpad 2026-03-26 11:10:52 +00:00
Pabloader b770e5e394 ContentEditable in chat 2026-03-26 11:06:38 +00:00
Pabloader c4d10113e0 Blockquote highlight 2026-03-26 11:06:22 +00:00
6 changed files with 80 additions and 13 deletions

View File

@ -67,6 +67,16 @@
padding-left: 1.5em; padding-left: 1.5em;
} }
.blockquote {
display: block;
opacity: .5;
white-space: pre-wrap;
word-wrap: break-word;
border-left: 2px solid currentColor;
margin-bottom: .5em;
padding-left: .5em;
}
.hr { .hr {
border: none; border: none;
border-top: 1px solid var(--hrColor, #555); border-top: 1px solid var(--hrColor, #555);

View File

@ -41,12 +41,14 @@ export const parseTable = (table: string): string => {
export const highlight = (message: string, keepMarkup = true): string => { export const highlight = (message: string, keepMarkup = true): string => {
let resultHTML = ''; let resultHTML = '';
const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)#{1,3} |\n)/g; const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)#{1,3} |(?:^|\n)> |\n)/g;
const headerRegex = /#{1,3} $/; const headerRegex = /#{1,3} $/;
const blockquoteRegex = /> $/;
const stack: string[] = []; const stack: string[] = [];
let inCodeBlock = false; let inCodeBlock = false;
let inMonospaced = false; let inMonospaced = false;
let inHeader = false; let inHeader = false;
let inBlockquote = false;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@ -83,6 +85,7 @@ export const highlight = (message: string, keepMarkup = true): string => {
const headerMatch = token.match(headerRegex); const headerMatch = token.match(headerRegex);
if (headerMatch) { if (headerMatch) {
if (inHeader) resultHTML += '</span>'; if (inHeader) resultHTML += '</span>';
if (inBlockquote) { resultHTML += '</span>'; inBlockquote = false; }
const markup = keepMarkup ? headerMatch[0] : ''; const markup = keepMarkup ? headerMatch[0] : '';
const len = headerMatch[0].length; const len = headerMatch[0].length;
inHeader = true; inHeader = true;
@ -90,10 +93,23 @@ export const highlight = (message: string, keepMarkup = true): string => {
continue; continue;
} }
const blockquoteMatch = token.match(blockquoteRegex);
if (blockquoteMatch) {
if (inHeader) { resultHTML += '</span>'; inHeader = false; }
if (inBlockquote) resultHTML += '</span>';
const markup = keepMarkup ? '> ' : '';
inBlockquote = true;
resultHTML += `${token.slice(0, -2)}<span class="${styles.blockquote}">${markup}`;
continue;
}
if (token === '\n') { if (token === '\n') {
if (inHeader) { if (inHeader) {
resultHTML += `${keepMarkup ? '\n' : ''}</span>`; resultHTML += `${keepMarkup ? '\n' : ''}</span>`;
inHeader = false; inHeader = false;
} else if (inBlockquote) {
resultHTML += `${keepMarkup ? '\n' : ''}</span>`;
inBlockquote = false;
} else { } else {
resultHTML += '\n'; resultHTML += '\n';
} }
@ -126,6 +142,7 @@ export const highlight = (message: string, keepMarkup = true): string => {
resultHTML += message.slice(lastIndex); resultHTML += message.slice(lastIndex);
if (inHeader) resultHTML += '</span>'; if (inHeader) resultHTML += '</span>';
if (inBlockquote) resultHTML += '</span>';
resultHTML += '</span>'.repeat(stack.length); resultHTML += '</span>'.repeat(stack.length);
if (!keepMarkup) { if (!keepMarkup) {

View File

@ -10,6 +10,7 @@ import Prompt from "../utils/prompt";
import { Tools } from "../utils/tools"; import { Tools } from "../utils/tools";
import { Sparkles } from "lucide-preact"; import { Sparkles } from "lucide-preact";
import clsx from "clsx"; import clsx from "clsx";
import { ContentEditable } from "@common/components/ContentEditable";
// ─── Role Header ────────────────────────────────────────────────────────────── // ─── Role Header ──────────────────────────────────────────────────────────────
@ -350,13 +351,18 @@ export const ChatSidebar = () => {
</button> </button>
</div> </div>
</div> </div>
<textarea <ContentEditable
autoLines
class={styles.input} class={styles.input}
value={input} value={input}
onInput={setInput} onInput={setInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={isDisabled ? 'Connect to an LLM server to chat' : 'Type a message...'} placeholder={
rows={3} isLoading
? 'Generating...'
: isDisabled
? 'Connect to an LLM server to chat'
: 'Type a message...'}
disabled={isDisabled} disabled={isDisabled}
/> />
{isLoading ? ( {isLoading ? (

View File

@ -2,7 +2,7 @@ import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight"; import { highlight } from "@common/highlight";
import { useAppState, type Tab } from "../contexts/state"; import { useAppState, type Tab } from "../contexts/state";
import styles from '../assets/editor.module.css'; import styles from '../assets/editor.module.css';
import { useMemo } from "preact/hooks"; import { useMemo, useRef, useEffect } from "preact/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { CharacterEditor } from "./character-editor"; import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor"; import { LocationEditor } from "./location-editor";
@ -17,7 +17,8 @@ const TABS: { id: Tab; label: string; right?: boolean }[] = [
{ id: "lore", label: "Lore" }, { id: "lore", label: "Lore" },
{ id: "characters", label: "Characters" }, { id: "characters", label: "Characters" },
{ id: "locations", label: "Locations" }, { id: "locations", label: "Locations" },
{ id: "prompt", label: "Prompt", right: true }, { id: "scratchpad", label: "Scratchpad", right: true },
{ id: "prompt", label: "Prompt" },
]; ];
export const Editor = () => { export const Editor = () => {
@ -26,11 +27,12 @@ export const Editor = () => {
const handleInput = useInputCallback((text: string) => { const handleInput = useInputCallback((text: string) => {
if (!currentStory) return; if (!currentStory) return;
dispatch({ dispatch({ type: 'EDIT_STORY', id: currentStory.id, text });
type: 'EDIT_STORY', }, [currentStory?.id]);
id: currentStory.id,
text, const handleScratchpadInput = useInputCallback((text: string) => {
}); if (!currentStory) return;
dispatch({ type: 'EDIT_STORY', id: currentStory.id, text });
}, [currentStory?.id]); }, [currentStory?.id]);
const handleTabChange = (tab: Tab) => { const handleTabChange = (tab: Tab) => {
@ -42,6 +44,14 @@ export const Editor = () => {
}); });
}; };
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [currentStory?.currentTab]);
const storyValue = useMemo(() => currentStory ? highlight(currentStory.text) : '', [currentStory?.text]); const storyValue = useMemo(() => currentStory ? highlight(currentStory.text) : '', [currentStory?.text]);
const promptPreview = useMemo(() => { const promptPreview = useMemo(() => {
if (currentStory?.currentTab !== 'prompt') return ''; if (currentStory?.currentTab !== 'prompt') return '';
@ -58,7 +68,7 @@ export const Editor = () => {
<div class={styles.title}> <div class={styles.title}>
{currentStory.title} {currentStory.title}
</div> </div>
<div class={styles.content}> <div class={styles.content} ref={contentRef}>
{currentStory.currentTab === "story" && ( {currentStory.currentTab === "story" && (
<ContentEditable <ContentEditable
class={styles.editable} class={styles.editable}
@ -79,6 +89,14 @@ export const Editor = () => {
{currentStory.currentTab === "chapters" && ( {currentStory.currentTab === "chapters" && (
<ChaptersEditor /> <ChaptersEditor />
)} )}
{currentStory.currentTab === "scratchpad" && (
<ContentEditable
class={styles.editable}
value={currentStory.scratchpad ?? ''}
onInput={handleScratchpadInput}
placeholder="Notes, ideas, outlines — anything you don't want in the story..."
/>
)}
{currentStory.currentTab === "prompt" && ( {currentStory.currentTab === "prompt" && (
<div class={styles.promptPreview} dangerouslySetInnerHTML={{ __html: promptPreview }} /> <div class={styles.promptPreview} dangerouslySetInnerHTML={{ __html: promptPreview }} />
)} )}

View File

@ -11,7 +11,7 @@ export type ChatMessage = LLM.ChatMessage & {
id: string; id: string;
} }
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters" | "prompt"; export type Tab = "story" | "lore" | "characters" | "locations" | "chapters" | "scratchpad" | "prompt";
export enum CharacterRole { export enum CharacterRole {
Protagonist = 'protagonist', Protagonist = 'protagonist',
@ -67,6 +67,7 @@ export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
scratchpad: string;
lore: LoreEntry[]; lore: LoreEntry[];
characters: Character[]; characters: Character[];
locations: Location[]; locations: Location[];
@ -93,6 +94,7 @@ type Action =
| { type: 'CREATE_STORY'; title: string } | { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string } | { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string } | { type: 'EDIT_STORY'; id: string; text: string }
| { type: 'EDIT_SCRATCHPAD'; id: string; text: string }
| { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry } | { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry }
| { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial<LoreEntry> } | { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial<LoreEntry> }
| { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string } | { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string }
@ -141,6 +143,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: action.title, title: action.title,
text: '', text: '',
scratchpad: '',
lore: [], lore: [],
characters: [], characters: [],
locations: [], locations: [],
@ -256,6 +259,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: `${original.title} (Copy)`, title: `${original.title} (Copy)`,
text: '', text: '',
scratchpad: '',
lore: [...original.lore], lore: [...original.lore],
characters: original.characters, characters: original.characters,
locations: original.locations, locations: original.locations,
@ -483,6 +487,14 @@ function reducer(state: IState, action: Action): IState {
}), }),
}; };
} }
case 'EDIT_SCRATCHPAD': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, scratchpad: action.text } : s
),
};
}
case 'STORE_CHAPTER_SUMMARY': { case 'STORE_CHAPTER_SUMMARY': {
return { return {
...state, ...state,

View File

@ -299,6 +299,10 @@ namespace Prompt {
parts.push(locationsSection); parts.push(locationsSection);
} }
if (currentStory.scratchpad) {
parts.push(`## Scratchpad`, currentStory.scratchpad);
}
if (currentStory.text && storyTokenBudget > 0) { if (currentStory.text && storyTokenBudget > 0) {
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
if (storyText) { if (storyText) {