Compare commits
3 Commits
0595c98991
...
c36e5654ed
| Author | SHA1 | Date |
|---|---|---|
|
|
c36e5654ed | |
|
|
b770e5e394 | |
|
|
c4d10113e0 |
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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 }} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue