Compare commits
No commits in common. "12214dd2dfebf4950e09b79451508c1864cd345d" and "23ea3eb7dba17564cf15c7e90f4f1cc74c236314" have entirely different histories.
12214dd2df
...
23ea3eb7db
|
|
@ -32,82 +32,6 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message:hover .messageActions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 4px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.saveButton:hover:not(:disabled) {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelButton:hover:not(:disabled) {
|
|
||||||
color: var(--error, #f44336);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editTextarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 80px;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
resize: vertical;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message[data-role="user"] {
|
.message[data-role="user"] {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +62,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.reasoningContent {
|
.reasoningContent {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
border-left: 2px solid currentColor;
|
border-left: 2px solid currentColor;
|
||||||
|
|
@ -149,7 +73,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
@ -320,29 +244,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.regenerateButton {
|
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ 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, ChevronsRight, Trash2, Edit2, Check, X, RefreshCw } 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";
|
||||||
|
|
||||||
|
|
@ -54,10 +54,6 @@ export const ChatPanel = () => {
|
||||||
const messagesRef = useRef<HTMLDivElement>(null);
|
const messagesRef = useRef<HTMLDivElement>(null);
|
||||||
const abortControllerRef = useRef<AbortController>(new AbortController());
|
const abortControllerRef = useRef<AbortController>(new AbortController());
|
||||||
|
|
||||||
// Edit state
|
|
||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
|
||||||
const [editingContent, setEditingContent] = useInputState('');
|
|
||||||
|
|
||||||
const appStateRef = useRef(appState);
|
const appStateRef = useRef(appState);
|
||||||
appStateRef.current = appState;
|
appStateRef.current = appState;
|
||||||
|
|
||||||
|
|
@ -308,69 +304,6 @@ export const ChatPanel = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMessage = useCallback((messageId: string) => {
|
|
||||||
if (!currentStory || !currentWorld) return;
|
|
||||||
dispatch({
|
|
||||||
type: 'DELETE_CHAT_MESSAGES_FROM',
|
|
||||||
worldId: currentWorld.id,
|
|
||||||
storyId: currentStory.id,
|
|
||||||
messageId,
|
|
||||||
});
|
|
||||||
}, [currentStory, currentWorld, dispatch]);
|
|
||||||
|
|
||||||
const handleStartEdit = useCallback((message: ChatMessage) => {
|
|
||||||
setEditingMessageId(message.id);
|
|
||||||
setEditingContent(message.content);
|
|
||||||
}, [setEditingContent]);
|
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(() => {
|
|
||||||
if (!currentStory || !currentWorld || !editingMessageId) return;
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'EDIT_CHAT_MESSAGE',
|
|
||||||
worldId: currentWorld.id,
|
|
||||||
storyId: currentStory.id,
|
|
||||||
messageId: editingMessageId,
|
|
||||||
content: editingContent.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setEditingMessageId(null);
|
|
||||||
setEditingContent('');
|
|
||||||
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]);
|
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
|
||||||
setEditingMessageId(null);
|
|
||||||
setEditingContent('');
|
|
||||||
}, [setEditingContent]);
|
|
||||||
|
|
||||||
const handleRegenerate = useCallback(async () => {
|
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
|
||||||
|
|
||||||
// Only regenerate if last message is assistant
|
|
||||||
const lastMessage = currentStory.chatMessages.at(-1);
|
|
||||||
if (!lastMessage || lastMessage.role !== 'assistant') return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete the last assistant message and regenerate
|
|
||||||
dispatch({
|
|
||||||
type: 'DELETE_CHAT_MESSAGES_FROM',
|
|
||||||
worldId: currentWorld!.id,
|
|
||||||
storyId: currentStory.id,
|
|
||||||
messageId: lastMessage.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the message history before the deleted assistant message
|
|
||||||
const messages = currentStory.chatMessages.slice(0, -1);
|
|
||||||
await sendMessage(messages);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentStory, currentWorld, connection, model, isLoading, dispatch, sendMessage]);
|
|
||||||
|
|
||||||
const isDisabled = !currentStory || !connection || !model || isLoading;
|
const isDisabled = !currentStory || !connection || !model || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -389,74 +322,10 @@ export const ChatPanel = () => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class={styles.messages} ref={messagesRef}>
|
<div class={styles.messages} ref={messagesRef}>
|
||||||
{currentStory.chatMessages.map((message, index) => {
|
{currentStory.chatMessages.map((message) => (
|
||||||
const isEditing = editingMessageId === message.id;
|
|
||||||
const canEdit = message.role === 'user' || message.role === 'assistant';
|
|
||||||
const canDelete = index < currentStory.chatMessages.length - 1 || message.role === 'user' || message.role === 'assistant';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={message.id} class={styles.message} data-role={message.role}>
|
<div key={message.id} class={styles.message} data-role={message.role}>
|
||||||
<div class={styles.messageHeader}>
|
|
||||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
||||||
|
|
||||||
{!isLoading && canEdit && (
|
|
||||||
<div class={styles.messageActions}>
|
|
||||||
<button
|
|
||||||
class={styles.iconButton}
|
|
||||||
onClick={() => handleStartEdit(message)}
|
|
||||||
title="Edit message"
|
|
||||||
>
|
|
||||||
<Edit2 size={12} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={styles.iconButton}
|
|
||||||
onClick={() => handleDeleteMessage(message.id)}
|
|
||||||
title="Delete this and all following messages"
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEditing ? (
|
|
||||||
<div class={styles.editContainer}>
|
|
||||||
<ContentEditable
|
|
||||||
autoLines
|
|
||||||
class={styles.editTextarea}
|
|
||||||
value={highlight(editingContent)}
|
|
||||||
onInput={setEditingContent}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSaveEdit();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleCancelEdit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class={styles.editActions}>
|
|
||||||
<button
|
|
||||||
class={clsx(styles.iconButton, styles.saveButton)}
|
|
||||||
onClick={handleSaveEdit}
|
|
||||||
disabled={!editingContent.trim()}
|
|
||||||
title="Save (Ctrl+Enter)"
|
|
||||||
>
|
|
||||||
<Check size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={clsx(styles.iconButton, styles.cancelButton)}
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
title="Cancel (Esc)"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{message.role === 'assistant' && message.reasoning_content && (
|
{message.role === 'assistant' && message.reasoning_content && (
|
||||||
<div class={styles.reasoningContent}>
|
<div class={styles.reasoningContent}>
|
||||||
{message.reasoning_content}
|
{message.reasoning_content}
|
||||||
|
|
@ -477,11 +346,8 @@ export const ChatPanel = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
||||||
<div class={styles.role}>error</div>
|
<div class={styles.role}>error</div>
|
||||||
|
|
@ -522,7 +388,7 @@ export const ChatPanel = () => {
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
autoLines
|
autoLines
|
||||||
class={styles.input}
|
class={styles.input}
|
||||||
value={highlight(input)}
|
value={input}
|
||||||
onInput={setInput}
|
onInput={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
|
|
@ -559,16 +425,6 @@ export const ChatPanel = () => {
|
||||||
<ChevronsRight size={14} />
|
<ChevronsRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{lastMessage?.role === 'assistant' && (
|
|
||||||
<button
|
|
||||||
class={styles.regenerateButton}
|
|
||||||
onClick={handleRegenerate}
|
|
||||||
disabled={isDisabled}
|
|
||||||
title="Regenerate last response"
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,6 @@ export const Editor = () => {
|
||||||
return highlight(text, false);
|
return highlight(text, false);
|
||||||
}, [currentTab, appState]);
|
}, [currentTab, appState]);
|
||||||
|
|
||||||
const overrideValue = useMemo(() => {
|
|
||||||
return highlight(currentWorld?.systemInstructionOverride || '');
|
|
||||||
}, [currentWorld?.systemInstructionOverride]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStory?.lastEditedText) {
|
if (currentStory?.lastEditedText) {
|
||||||
const raf = requestAnimationFrame(() => {
|
const raf = requestAnimationFrame(() => {
|
||||||
|
|
@ -187,7 +183,7 @@ export const Editor = () => {
|
||||||
{currentTab === "system" && currentWorld && (
|
{currentTab === "system" && currentWorld && (
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
class={styles.editable}
|
class={styles.editable}
|
||||||
value={overrideValue}
|
value={currentWorld.systemInstructionOverride ?? ''}
|
||||||
onInput={handleSystemOverrideInput}
|
onInput={handleSystemOverrideInput}
|
||||||
placeholder="Override the global system instruction for this world. Leave empty to use the global setting."
|
placeholder="Override the global system instruction for this world. Leave empty to use the global setting."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { useAppState } from "../contexts/state";
|
||||||
import { useBool } from "@common/hooks/useBool";
|
import { useBool } from "@common/hooks/useBool";
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
import type { World, Story } from "../contexts/state";
|
import type { World, Story } from "../contexts/state";
|
||||||
import { isWorld } from "../contexts/state";
|
|
||||||
import CharacterCard from "../utils/character-card";
|
|
||||||
import styles from '../assets/menu.module.css';
|
import styles from '../assets/menu.module.css';
|
||||||
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload, MessagesSquare, MessageSquarePlus } from "lucide-preact";
|
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload, MessagesSquare, MessageSquarePlus } from "lucide-preact";
|
||||||
|
|
||||||
|
|
@ -259,32 +257,20 @@ export const Menu = () => {
|
||||||
const handleImportWorld = () => {
|
const handleImportWorld = () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.json,.png,application/json,image/png';
|
input.accept = '.json,application/json';
|
||||||
input.onchange = async () => {
|
input.onchange = () => {
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.name.endsWith('.png')) {
|
|
||||||
const card = await CharacterCard.extractFromPng(file);
|
|
||||||
if (!card) { alert('Invalid character card PNG.'); return; }
|
|
||||||
dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(card) });
|
|
||||||
} else {
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(reader.result as string);
|
const world = JSON.parse(reader.result as string);
|
||||||
if (CharacterCard.isV2(parsed)) {
|
dispatch({ type: 'IMPORT_WORLD', world });
|
||||||
dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) });
|
|
||||||
} else if (isWorld(parsed)) {
|
|
||||||
dispatch({ type: 'IMPORT_WORLD', world: parsed });
|
|
||||||
} else {
|
|
||||||
alert('Invalid file.');
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
alert('Invalid file.');
|
alert('Invalid world file.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -88,22 +88,6 @@ export interface World {
|
||||||
systemInstructionOverride?: string;
|
systemInstructionOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Type Guards ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function isWorld(obj: unknown): obj is World {
|
|
||||||
if (typeof obj !== 'object' || obj === null) return false;
|
|
||||||
const w = obj as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof w.id === 'string' &&
|
|
||||||
typeof w.title === 'string' &&
|
|
||||||
typeof w.chatOnly === 'boolean' &&
|
|
||||||
Array.isArray(w.lore) &&
|
|
||||||
Array.isArray(w.characters) &&
|
|
||||||
Array.isArray(w.locations) &&
|
|
||||||
Array.isArray(w.stories)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
|
@ -148,8 +132,6 @@ type Action =
|
||||||
// Chat
|
// Chat
|
||||||
| { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
|
| { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
|
||||||
| { type: 'CLEAR_CHAT'; worldId: string; storyId: string }
|
| { type: 'CLEAR_CHAT'; worldId: string; storyId: string }
|
||||||
| { type: 'DELETE_CHAT_MESSAGES_FROM'; worldId: string; storyId: string; messageId: string }
|
|
||||||
| { type: 'EDIT_CHAT_MESSAGE'; worldId: string; storyId: string; messageId: string; content: string }
|
|
||||||
// Connection
|
// Connection
|
||||||
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
||||||
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
|
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
|
||||||
|
|
@ -256,7 +238,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
worlds: [...state.worlds, world],
|
worlds: [...state.worlds, world],
|
||||||
currentWorldId: world.id,
|
currentWorldId: world.id,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'menu',
|
currentTab: action.chatOnly ? 'system' : 'lore',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_WORLD': {
|
case 'RENAME_WORLD': {
|
||||||
|
|
@ -273,11 +255,12 @@ function reducer(state: IState, action: Action): IState {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'SELECT_WORLD': {
|
case 'SELECT_WORLD': {
|
||||||
|
const world = state.worlds.find(w => w.id === action.worldId);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'menu',
|
currentTab: world?.chatOnly ? 'system' : 'lore',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'CREATE_STORY': {
|
case 'CREATE_STORY': {
|
||||||
|
|
@ -298,7 +281,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: story.id,
|
currentStoryId: story.id,
|
||||||
currentTab: 'menu',
|
currentTab: world?.chatOnly ? 'chat' : 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_STORY': {
|
case 'RENAME_STORY': {
|
||||||
|
|
@ -337,8 +320,6 @@ function reducer(state: IState, action: Action): IState {
|
||||||
const world = state.worlds.find(w => w.id === action.worldId);
|
const world = state.worlds.find(w => w.id === action.worldId);
|
||||||
const original = world?.stories.find(s => s.id === action.id);
|
const original = world?.stories.find(s => s.id === action.id);
|
||||||
if (!original) return state;
|
if (!original) return state;
|
||||||
const firstMessage = original.chatMessages[0];
|
|
||||||
const chatMessages = world?.chatOnly && firstMessage && firstMessage.role === 'assistant' ? [firstMessage] : [];
|
|
||||||
const newStory: Story = {
|
const newStory: Story = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: `${original.title} (Copy)`,
|
title: `${original.title} (Copy)`,
|
||||||
|
|
@ -347,13 +328,13 @@ function reducer(state: IState, action: Action): IState {
|
||||||
lore: [...original.lore],
|
lore: [...original.lore],
|
||||||
characters: [...original.characters],
|
characters: [...original.characters],
|
||||||
locations: [...original.locations],
|
locations: [...original.locations],
|
||||||
chatMessages,
|
chatMessages: [],
|
||||||
chapters: [],
|
chapters: [],
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
||||||
currentStoryId: newStory.id,
|
currentStoryId: newStory.id,
|
||||||
currentTab: 'menu',
|
currentTab: world?.chatOnly ? 'chat' : 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'ADD_LORE_ENTRY': {
|
case 'ADD_LORE_ENTRY': {
|
||||||
|
|
@ -409,21 +390,6 @@ function reducer(state: IState, action: Action): IState {
|
||||||
case 'CLEAR_CHAT': {
|
case 'CLEAR_CHAT': {
|
||||||
return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
|
return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
|
||||||
}
|
}
|
||||||
case 'DELETE_CHAT_MESSAGES_FROM': {
|
|
||||||
return updateStory(state, action.worldId, action.storyId, s => {
|
|
||||||
const messageIndex = s.chatMessages.findIndex(m => m.id === action.messageId);
|
|
||||||
if (messageIndex === -1) return s;
|
|
||||||
return { ...s, chatMessages: s.chatMessages.slice(0, messageIndex) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case 'EDIT_CHAT_MESSAGE': {
|
|
||||||
return updateStory(state, action.worldId, action.storyId, s => ({
|
|
||||||
...s,
|
|
||||||
chatMessages: s.chatMessages.map(m =>
|
|
||||||
m.id === action.messageId ? { ...m, content: action.content } : m
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
case 'SET_CONNECTION': {
|
case 'SET_CONNECTION': {
|
||||||
return { ...state, connection: action.connection };
|
return { ...state, connection: action.connection };
|
||||||
}
|
}
|
||||||
|
|
@ -548,7 +514,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
worlds: [...state.worlds, world],
|
worlds: [...state.worlds, world],
|
||||||
currentWorldId: world.id,
|
currentWorldId: world.id,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'menu',
|
currentTab: world.chatOnly ? 'system' : 'lore',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
import type { World, Story } from "../contexts/state";
|
|
||||||
|
|
||||||
// ─── Spec Types ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
namespace CharacterCard {
|
|
||||||
export interface V2Data {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
personality: string;
|
|
||||||
scenario: string;
|
|
||||||
first_mes: string;
|
|
||||||
mes_example: string;
|
|
||||||
system_prompt?: string;
|
|
||||||
alternate_greetings?: string[];
|
|
||||||
post_history_instructions?: string;
|
|
||||||
creator_notes?: string;
|
|
||||||
tags?: string[];
|
|
||||||
creator?: string;
|
|
||||||
character_version?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface V2 {
|
|
||||||
spec: "chara_card_v2";
|
|
||||||
spec_version: string;
|
|
||||||
data: V2Data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Type Guard ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function isV2(obj: unknown): obj is V2 {
|
|
||||||
if (typeof obj !== 'object' || obj === null) return false;
|
|
||||||
const c = obj as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
c.spec === 'chara_card_v2' &&
|
|
||||||
typeof c.spec_version === 'string' &&
|
|
||||||
typeof c.data === 'object' && c.data !== null &&
|
|
||||||
typeof (c.data as Record<string, unknown>).name === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PNG Parsing ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts and parses the chara_card_v2 JSON embedded in a PNG tEXt chunk.
|
|
||||||
* Returns null if the file is not a valid V2 character card.
|
|
||||||
*/
|
|
||||||
export async function extractFromPng(file: File): Promise<V2 | null> {
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
|
|
||||||
// Validate PNG magic bytes
|
|
||||||
const PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
||||||
for (let i = 0; i < PNG_MAGIC.length; i++) {
|
|
||||||
if (bytes[i] !== PNG_MAGIC[i]) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
let offset = 8; // skip magic
|
|
||||||
|
|
||||||
while (offset + 12 <= bytes.length) {
|
|
||||||
const length = view.getUint32(offset);
|
|
||||||
const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
|
|
||||||
|
|
||||||
if (type === 'tEXt') {
|
|
||||||
const dataStart = offset + 8;
|
|
||||||
const dataEnd = dataStart + length;
|
|
||||||
const chunkData = bytes.slice(dataStart, dataEnd);
|
|
||||||
|
|
||||||
// Find null separator between keyword and text
|
|
||||||
const nullIdx = chunkData.indexOf(0);
|
|
||||||
if (nullIdx === -1) { offset += 12 + length; continue; }
|
|
||||||
|
|
||||||
const keyword = new TextDecoder().decode(chunkData.slice(0, nullIdx));
|
|
||||||
if (keyword !== 'chara') { offset += 12 + length; continue; }
|
|
||||||
|
|
||||||
const encoded = new TextDecoder('latin1').decode(chunkData.slice(nullIdx + 1));
|
|
||||||
const jsonBytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
|
|
||||||
const json = new TextDecoder('utf-8').decode(jsonBytes);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const card = JSON.parse(json);
|
|
||||||
if (card?.spec === 'chara_card_v2' && card?.data) {
|
|
||||||
return card as V2;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'IEND') break;
|
|
||||||
offset += 12 + length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Variable Substitution ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function substituteVars(text: string, charName: string): string {
|
|
||||||
return text
|
|
||||||
.replaceAll('{{char}}', charName)
|
|
||||||
.replaceAll('{{Char}}', charName)
|
|
||||||
.replaceAll('{{user}}', 'User')
|
|
||||||
.replaceAll('{{User}}', 'User');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Formatting ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const DEFAULT_SYSTEM_INSTRUCTION =
|
|
||||||
`You are roleplaying as the character described below. Stay in character at all times. Write in first person from the character's perspective. Keep responses engaging and true to the character's personality and background.`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the systemInstructionOverride from a V2 card's data fields.
|
|
||||||
* Mirrors the formatting style used in prompt.ts.
|
|
||||||
*/
|
|
||||||
export function formatSystemPrompt(data: V2Data): string {
|
|
||||||
const sub = (text: string) => substituteVars(text, data.name);
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
parts.push(data.system_prompt ? sub(data.system_prompt.trim()) : DEFAULT_SYSTEM_INSTRUCTION);
|
|
||||||
|
|
||||||
if (data.description?.trim()) {
|
|
||||||
parts.push(`## Description\n${sub(data.description.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.personality?.trim()) {
|
|
||||||
parts.push(`## Personality\n${sub(data.personality.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.scenario?.trim()) {
|
|
||||||
parts.push(`## Scenario\n${sub(data.scenario.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.mes_example?.trim()) {
|
|
||||||
parts.push(`## Example Messages\n${sub(data.mes_example.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── World Builder ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function greetingTitle(mes: string): string {
|
|
||||||
const snippet = mes.trim().slice(0, 40);
|
|
||||||
return snippet.length < mes.trim().length ? `${snippet}…` : snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a V2 character card into a chat-only World.
|
|
||||||
* Creates one story per greeting (first_mes + alternate_greetings).
|
|
||||||
*/
|
|
||||||
export function toWorld(card: V2): World {
|
|
||||||
const { data } = card;
|
|
||||||
|
|
||||||
const greetings: string[] = [
|
|
||||||
...(data.first_mes?.trim() ? [data.first_mes.trim()] : []),
|
|
||||||
...(data.alternate_greetings ?? []).filter(g => g?.trim()).map(g => g.trim()),
|
|
||||||
];
|
|
||||||
|
|
||||||
const stories: Story[] = greetings.map(mes => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
title: greetingTitle(mes),
|
|
||||||
text: '',
|
|
||||||
scratchpad: '',
|
|
||||||
lore: [],
|
|
||||||
characters: [],
|
|
||||||
locations: [],
|
|
||||||
chatMessages: [{ id: crypto.randomUUID(), role: 'assistant', content: substituteVars(mes, data.name) }],
|
|
||||||
chapters: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (stories.length === 0) {
|
|
||||||
stories.push({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
title: 'Chat',
|
|
||||||
text: '',
|
|
||||||
scratchpad: '',
|
|
||||||
lore: [],
|
|
||||||
characters: [],
|
|
||||||
locations: [],
|
|
||||||
chatMessages: [],
|
|
||||||
chapters: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
title: data.name || 'Imported Character',
|
|
||||||
chatOnly: true,
|
|
||||||
lore: [],
|
|
||||||
characters: [],
|
|
||||||
locations: [],
|
|
||||||
stories,
|
|
||||||
systemInstructionOverride: formatSystemPrompt(data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CharacterCard;
|
|
||||||
Loading…
Reference in New Issue