Compare commits
No commits in common. "d9dac5c97f47a36928ed673db80f0d833437b8b4" and "f685118da0f5c86048c1d0c9502de6915ca1b04a" have entirely different histories.
d9dac5c97f
...
f685118da0
|
|
@ -15,8 +15,8 @@
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 720px;
|
max-width: 500px;
|
||||||
height: 80vh;
|
max-height: 80vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
|
@ -56,35 +56,25 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
border-bottom: 1px solid var(--border);
|
||||||
overflow: hidden;
|
padding: 0 20px;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.tab {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 160px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
padding: 8px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuItem {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
gap: 6px;
|
||||||
padding: 10px 12px;
|
padding: 12px 16px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-bottom: 2px solid transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: left;
|
transition: all 0.2s;
|
||||||
transition: all 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -93,8 +83,7 @@
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--bg-active);
|
border-bottom-color: var(--accent);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,15 +91,12 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.formGroup {
|
.formGroup {
|
||||||
|
|
@ -118,10 +104,6 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formGroupFill {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|
@ -140,11 +122,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
resize: none;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
|
import { useAppState } from "../contexts/state";
|
||||||
|
import Chapters from "../utils/chapters";
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
import { highlight } from "@common/highlight";
|
import { highlight } from "@common/highlight";
|
||||||
import { useAppState } from "../../contexts/state";
|
import styles from "../assets/chapters-editor.module.css";
|
||||||
import Chapters from "../../utils/chapters";
|
|
||||||
import styles from "../../assets/chapters-editor.module.css";
|
|
||||||
|
|
||||||
export const ChaptersEditor = () => {
|
export const ChaptersEditor = () => {
|
||||||
const { currentWorld, currentStory, dispatch } = useAppState();
|
const { currentWorld, currentStory, dispatch } = useAppState();
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { CharacterRole, useAppState, type Character } from "../contexts/state";
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";import { CharacterRole, useAppState, type Character } from "../../contexts/state";
|
import styles from '../assets/character-editor.module.css';
|
||||||
import styles from '../../assets/character-editor.module.css';
|
import LLM from "../utils/llm";
|
||||||
import LLM from "../../utils/llm";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
|
|
||||||
export const CharacterEditor = () => {
|
export const CharacterEditor = () => {
|
||||||
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
|
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
|
||||||
|
|
@ -186,8 +186,8 @@ export const ChatPanel = () => {
|
||||||
const content = delta?.content;
|
const content = delta?.content;
|
||||||
if (content) {
|
if (content) {
|
||||||
accumulatedContent += content;
|
accumulatedContent += content;
|
||||||
if (currentWorld?.chatOnly && accumulatedContent.trimStart().startsWith(prefix)) {
|
if (currentWorld?.chatOnly && accumulatedContent.startsWith(prefix)) {
|
||||||
accumulatedContent = accumulatedContent.trimStart().slice(prefix.length).trimStart();
|
accumulatedContent = accumulatedContent.slice(prefix.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reasoningContent = delta?.reasoning_content;
|
const reasoningContent = delta?.reasoning_content;
|
||||||
|
|
@ -258,44 +258,8 @@ export const ChatPanel = () => {
|
||||||
}
|
}
|
||||||
}, [currentStory, connection, model]);
|
}, [currentStory, connection, model]);
|
||||||
|
|
||||||
const handleRegenerate = useCallback(async () => {
|
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
|
||||||
|
|
||||||
// Only regenerate if last message is assistant
|
|
||||||
const lastMessage = currentStory.chatMessages.at(-1);
|
|
||||||
const isAssistant = lastMessage?.role === 'assistant';
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
const excludedMessages: string[] = [];
|
|
||||||
try {
|
|
||||||
if (isAssistant) {
|
|
||||||
// Delete the last assistant message and regenerate
|
|
||||||
dispatch({
|
|
||||||
type: 'DELETE_CHAT_MESSAGES_FROM',
|
|
||||||
worldId: currentWorld!.id,
|
|
||||||
storyId: currentStory.id,
|
|
||||||
messageId: lastMessage.id,
|
|
||||||
});
|
|
||||||
excludedMessages.push(lastMessage.id);
|
|
||||||
}
|
|
||||||
await sendMessage([], excludedMessages);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
|
|
||||||
|
|
||||||
const handleSendMessage = useCallback(async () => {
|
const handleSendMessage = useCallback(async () => {
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
if (!currentStory || !input.trim() || !connection || !model || isLoading) return;
|
||||||
|
|
||||||
if (!input.trim()) {
|
|
||||||
if (lastMessage?.role === 'user') {
|
|
||||||
handleRegenerate();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -311,7 +275,7 @@ export const ChatPanel = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
|
}, [currentStory, input, connection, model, isLoading, sendMessage]);
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
const handleContinue = useCallback(async () => {
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
if (!currentStory || !connection || !model || isLoading) return;
|
||||||
|
|
@ -388,6 +352,32 @@ export const ChatPanel = () => {
|
||||||
setEditingContent('');
|
setEditingContent('');
|
||||||
}, [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,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMessage([], [lastMessage.id]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentStory, currentWorld, connection, model, isLoading, dispatch, sendMessage]);
|
||||||
|
|
||||||
const isDisabled = !currentStory || !connection || !model || isLoading;
|
const isDisabled = !currentStory || !connection || !model || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -511,9 +501,9 @@ export const ChatPanel = () => {
|
||||||
)}
|
)}
|
||||||
{currentStory && (
|
{currentStory && (
|
||||||
<div class={styles.inputContainer}>
|
<div class={styles.inputContainer}>
|
||||||
<div class={styles.optionsRow}>
|
{!currentWorld?.chatOnly && (
|
||||||
<label class={styles.toggleContainer}>
|
<div class={styles.optionsRow}>
|
||||||
{!currentWorld?.chatOnly && (<>
|
<label class={styles.toggleContainer}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enableThinking}
|
checked={enableThinking}
|
||||||
|
|
@ -524,21 +514,19 @@ export const ChatPanel = () => {
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<span>Enable thinking</span>
|
<span>Enable thinking</span>
|
||||||
</>)}
|
</label>
|
||||||
</label>
|
<div class={styles.tokenCounter}>
|
||||||
<div class={styles.tokenCounter}>
|
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
||||||
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
<button
|
||||||
|
class={styles.summarizeButton}
|
||||||
{!currentWorld?.chatOnly && (<button
|
onClick={summarizeAll}
|
||||||
class={styles.summarizeButton}
|
disabled={isSummarizing || !currentStory || !connection || !model}
|
||||||
onClick={summarizeAll}
|
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
|
||||||
disabled={isSummarizing || !currentStory || !connection || !model}
|
<Sparkles size={14} />
|
||||||
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
|
</button>
|
||||||
<Sparkles size={14} />
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
autoLines
|
autoLines
|
||||||
class={styles.input}
|
class={styles.input}
|
||||||
|
|
@ -565,7 +553,7 @@ export const ChatPanel = () => {
|
||||||
<button
|
<button
|
||||||
class={styles.sendButton}
|
class={styles.sendButton}
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled || !input.trim()}
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -579,14 +567,16 @@ export const ChatPanel = () => {
|
||||||
<ChevronsRight size={14} />
|
<ChevronsRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{lastMessage?.role === 'assistant' && (
|
||||||
class={styles.regenerateButton}
|
<button
|
||||||
onClick={handleRegenerate}
|
class={styles.regenerateButton}
|
||||||
disabled={isDisabled}
|
onClick={handleRegenerate}
|
||||||
title="Regenerate last response"
|
disabled={isDisabled}
|
||||||
>
|
title="Regenerate last response"
|
||||||
<RefreshCw size={14} />
|
>
|
||||||
</button>
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { useMemo, useRef } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useQuery } from "@common/hooks/useAsyncState";
|
||||||
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
|
import { useUpdate } from "@common/hooks/useUpdate";
|
||||||
|
|
||||||
|
import { useAppState } from "../contexts/state";
|
||||||
|
import LLM from "../utils/llm";
|
||||||
|
import styles from "../assets/settings-modal.module.css";
|
||||||
|
import { X } from "lucide-preact";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionSettingsModal = ({ onClose }: Props) => {
|
||||||
|
const { connection, model, dispatch } = useAppState();
|
||||||
|
const [url, setUrl] = useInputState(connection?.url ?? "");
|
||||||
|
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
|
||||||
|
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
|
||||||
|
const [update, triggerFetch] = useUpdate();
|
||||||
|
|
||||||
|
const urlRef = useRef(url);
|
||||||
|
const apiKeyRef = useRef(apiKey);
|
||||||
|
|
||||||
|
urlRef.current = url;
|
||||||
|
apiKeyRef.current = apiKey;
|
||||||
|
|
||||||
|
const connectionToFetch = useMemo<LLM.Connection | null>(() => {
|
||||||
|
const currentUrl = urlRef.current;
|
||||||
|
const currentApiKey = apiKeyRef.current;
|
||||||
|
if (!currentUrl || !currentApiKey) return null;
|
||||||
|
return { url: currentUrl, apiKey: currentApiKey };
|
||||||
|
}, [update]);
|
||||||
|
|
||||||
|
const fetchModels = useMemo(() => async (conn: LLM.Connection | null) => {
|
||||||
|
if (!conn) return [];
|
||||||
|
const r = await LLM.getModels(conn);
|
||||||
|
return r.data;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const modelsData = useQuery(fetchModels, connectionToFetch);
|
||||||
|
|
||||||
|
const isLoadingModels = connectionToFetch != null && modelsData == undefined;
|
||||||
|
const groupedModels = useMemo(() => {
|
||||||
|
const sorted = (modelsData ?? []).sort((a, b) => {
|
||||||
|
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
|
||||||
|
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
|
||||||
|
if (aWeight !== bWeight) {
|
||||||
|
return bWeight - aWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aContext = a.max_context ?? 0;
|
||||||
|
const bContext = b.max_context ?? 0;
|
||||||
|
if (aContext !== bContext) {
|
||||||
|
return bContext - aContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by context size
|
||||||
|
const groups = Map.groupBy(sorted, m => m.max_context ?? 0);
|
||||||
|
|
||||||
|
// Convert to array sorted by context size (bigger first)
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort((a, b) => b[0] - a[0])
|
||||||
|
.map(([context, models]) => ({ context, models }));
|
||||||
|
}, [modelsData]);
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (url && apiKey) {
|
||||||
|
triggerFetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && url && apiKey) {
|
||||||
|
triggerFetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_CONNECTION',
|
||||||
|
connection: connectionToFetch,
|
||||||
|
});
|
||||||
|
const selectedModelInfo = modelsData?.find(m => m.id === selectedModel) ?? null;
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_MODEL',
|
||||||
|
model: selectedModelInfo,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionToTest = url && apiKey ? { url, apiKey } : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.overlay} onClick={onClose}>
|
||||||
|
<div class={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div class={styles.header}>
|
||||||
|
<h2 class={styles.title}>Connection Settings</h2>
|
||||||
|
<button class={styles.closeButton} onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class={styles.content}>
|
||||||
|
<div class={styles.form} autocomplete="off">
|
||||||
|
<div class={styles.formGroup}>
|
||||||
|
<label class={styles.label}>
|
||||||
|
API URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onInput={setUrl}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="http://localhost:1234"
|
||||||
|
class={styles.input}
|
||||||
|
autocomplete="off"
|
||||||
|
name="api-url-random"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class={styles.formGroup}>
|
||||||
|
<label class={styles.label}>
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onInput={setApiKey}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="your-api-key"
|
||||||
|
class={styles.input}
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="api-key-random"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class={styles.formGroup}>
|
||||||
|
<label class={styles.label}>
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
{connectionToTest ? (
|
||||||
|
isLoadingModels ? (
|
||||||
|
<p>Loading models...</p>
|
||||||
|
) : groupedModels.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={setSelectedModel}
|
||||||
|
class={styles.select}
|
||||||
|
>
|
||||||
|
<option value="">Select a model</option>
|
||||||
|
{groupedModels.map(({ context, models }) => (
|
||||||
|
<optgroup key={context} label={`${context} context`}>
|
||||||
|
{models.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.support_tools ? '🔨' : ''}{m.support_thinking ? '🧠' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p>No models available</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p>Enter connection details to load models</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class={styles.footer}>
|
||||||
|
<button onClick={onClose} class={`${styles.button} ${styles.buttonSecondary}`}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleConfirm} class={`${styles.button} ${styles.buttonPrimary}`}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,10 +4,10 @@ import { useAppState, type Tab } from "../contexts/state";
|
||||||
import styles from '../assets/editor.module.css';
|
import styles from '../assets/editor.module.css';
|
||||||
import { useMemo, useRef, useEffect } from "preact/hooks";
|
import { useMemo, useRef, useEffect } from "preact/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { CharacterEditor } from "./editors/character";
|
import { CharacterEditor } from "./character-editor";
|
||||||
import { LocationEditor } from "./editors/location";
|
import { LocationEditor } from "./location-editor";
|
||||||
import { ChaptersEditor } from "./editors/chapters";
|
import { ChaptersEditor } from "./chapters-editor";
|
||||||
import { LoreEditor } from "./editors/lore";
|
import { LoreEditor } from "./lore-editor";
|
||||||
import { Menu } from "./menu";
|
import { Menu } from "./menu";
|
||||||
import { ChatPanel } from "./chat-sidebar";
|
import { ChatPanel } from "./chat-sidebar";
|
||||||
import { useInputCallback } from "@common/hooks/useInputCallback";
|
import { useInputCallback } from "@common/hooks/useInputCallback";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { useAppState, type Location, LocationScale } from "../contexts/state";
|
||||||
import { useAppState, type Location, LocationScale } from "../../contexts/state";
|
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import styles from '../../assets/location-editor.module.css';
|
import styles from '../assets/location-editor.module.css';
|
||||||
import LLM from "../../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
|
|
||||||
export const LocationEditor = () => {
|
export const LocationEditor = () => {
|
||||||
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
|
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { useAppState, type LoreEntry } from "../contexts/state";
|
||||||
|
import styles from '../assets/lore-editor.module.css';
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useAppState, type LoreEntry } from "../../contexts/state";
|
|
||||||
import styles from '../../assets/lore-editor.module.css';
|
|
||||||
|
|
||||||
export const LoreEditor = () => {
|
export const LoreEditor = () => {
|
||||||
const { currentWorld, currentStory, dispatch } = useAppState();
|
const { currentWorld, currentStory, dispatch } = useAppState();
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ConnectionSettingsModal } from "./connection-settings-modal";
|
||||||
import { SettingsModal } from "./settings-modal";
|
import { SettingsModal } from "./settings-modal";
|
||||||
import { useAppState } from "../contexts/state";
|
import { useAppState } from "../contexts/state";
|
||||||
import { useBool } from "@common/hooks/useBool";
|
import { useBool } from "@common/hooks/useBool";
|
||||||
|
|
@ -7,7 +8,7 @@ import type { World, Story } from "../contexts/state";
|
||||||
import { isWorld } from "../contexts/state";
|
import { isWorld } from "../contexts/state";
|
||||||
import CharacterCard from "../utils/character-card";
|
import CharacterCard from "../utils/character-card";
|
||||||
import styles from '../assets/menu.module.css';
|
import styles from '../assets/menu.module.css';
|
||||||
import { Pencil, X, Plus, 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";
|
||||||
|
|
||||||
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -190,6 +191,7 @@ const WorldItem = ({
|
||||||
|
|
||||||
export const Menu = () => {
|
export const Menu = () => {
|
||||||
const { worlds, currentWorld, currentStory, dispatch } = useAppState();
|
const { worlds, currentWorld, currentStory, dispatch } = useAppState();
|
||||||
|
const isConnectionSettingsOpen = useBool(false);
|
||||||
const isSettingsOpen = useBool(false);
|
const isSettingsOpen = useBool(false);
|
||||||
|
|
||||||
const handleCreateWorld = () => {
|
const handleCreateWorld = () => {
|
||||||
|
|
@ -321,10 +323,16 @@ export const Menu = () => {
|
||||||
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
|
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
|
||||||
<Settings size={16} /> Settings
|
<Settings size={16} /> Settings
|
||||||
</button>
|
</button>
|
||||||
|
<button class={styles.settingsButton} onClick={isConnectionSettingsOpen.toggle}>
|
||||||
|
<Plug size={16} /> Connection Settings
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isSettingsOpen.value && (
|
{isSettingsOpen.value && (
|
||||||
<SettingsModal onClose={isSettingsOpen.toggle} />
|
<SettingsModal onClose={isSettingsOpen.toggle} />
|
||||||
)}
|
)}
|
||||||
|
{isConnectionSettingsOpen.value && (
|
||||||
|
<ConnectionSettingsModal onClose={isConnectionSettingsOpen.toggle} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,61 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useState } from "preact/hooks";
|
import { useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
|
|
||||||
|
import { useAppState } from "../contexts/state";
|
||||||
import styles from "../assets/settings-modal.module.css";
|
import styles from "../assets/settings-modal.module.css";
|
||||||
import { X } from "lucide-preact";
|
import { X } from "lucide-preact";
|
||||||
import { BannedTokensSettings } from "./settings/banned-tokens";
|
import { useInputCallback } from "@common/hooks/useInputCallback";
|
||||||
import { SystemInstructionSettings } from "./settings/system-instruction";
|
|
||||||
import { ConnectionSettings } from "./settings/connection";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "banned-tokens" | "system-instruction" | "connection";
|
type Tab = "banned-tokens" | "system-instruction";
|
||||||
|
|
||||||
export const SettingsModal = ({ onClose }: Props) => {
|
export const SettingsModal = ({ onClose }: Props) => {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("connection");
|
const { bannedTokens, systemInstruction, dispatch } = useAppState();
|
||||||
|
const [inputValue, setInputValue] = useInputState();
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("banned-tokens");
|
||||||
|
|
||||||
|
// Save system instruction on every change
|
||||||
|
const setInstructionValue = useInputCallback((instructionValue) => {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_SYSTEM_INSTRUCTION",
|
||||||
|
systemInstruction: instructionValue,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const trimmed = inputValue.trim();
|
||||||
|
if (trimmed && !bannedTokens.includes(trimmed)) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_BANNED_TOKENS",
|
||||||
|
tokens: [...bannedTokens, trimmed],
|
||||||
|
});
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (token: string) => {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_BANNED_TOKENS",
|
||||||
|
tokens: bannedTokens.filter((t) => t !== token),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleAdd();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedTokens = [...bannedTokens].sort((a, b) =>
|
||||||
|
a.trim().toLowerCase().localeCompare(b.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.overlay} onClick={onClose}>
|
<div class={styles.overlay} onClick={onClose}>
|
||||||
|
|
@ -25,32 +66,72 @@ export const SettingsModal = ({ onClose }: Props) => {
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.body}>
|
<div class={styles.tabs}>
|
||||||
<nav class={styles.sidebar}>
|
<button
|
||||||
<button
|
class={clsx(styles.tab, activeTab === "banned-tokens" && styles.active)}
|
||||||
class={clsx(styles.menuItem, activeTab === "connection" && styles.active)}
|
onClick={() => setActiveTab("banned-tokens")}
|
||||||
onClick={() => setActiveTab("connection")}
|
>
|
||||||
>
|
Banned Tokens
|
||||||
Connection
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
class={clsx(styles.tab, activeTab === "system-instruction" && styles.active)}
|
||||||
class={clsx(styles.menuItem, activeTab === "banned-tokens" && styles.active)}
|
onClick={() => setActiveTab("system-instruction")}
|
||||||
onClick={() => setActiveTab("banned-tokens")}
|
>
|
||||||
>
|
System Instruction
|
||||||
Banned Tokens
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div class={styles.content}>
|
||||||
class={clsx(styles.menuItem, activeTab === "system-instruction" && styles.active)}
|
{activeTab === "banned-tokens" ? (
|
||||||
onClick={() => setActiveTab("system-instruction")}
|
<>
|
||||||
>
|
<div class={styles.inputRow}>
|
||||||
System Instruction
|
<input
|
||||||
</button>
|
type="text"
|
||||||
</nav>
|
value={inputValue}
|
||||||
<div class={styles.content}>
|
onInput={setInputValue}
|
||||||
{activeTab === "banned-tokens" && <BannedTokensSettings onClose={onClose} />}
|
onKeyDown={handleKeyDown}
|
||||||
{activeTab === "system-instruction" && <SystemInstructionSettings />}
|
placeholder="Token to ban"
|
||||||
{activeTab === "connection" && <ConnectionSettings />}
|
class={styles.input}
|
||||||
</div>
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={handleAdd} class={clsx(styles.button, styles.buttonPrimary)}>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class={styles.divider} />
|
||||||
|
<div class={styles.tokenList}>
|
||||||
|
{sortedTokens.length === 0 ? (
|
||||||
|
<p class={styles.emptyText}>No banned tokens</p>
|
||||||
|
) : (
|
||||||
|
sortedTokens.map((token) => (
|
||||||
|
<div key={token} class={styles.tokenItem}>
|
||||||
|
<span>{token}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(token)}
|
||||||
|
class={styles.tokenRemoveButton}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div class={styles.form}>
|
||||||
|
<div class={styles.formGroup}>
|
||||||
|
<label class={styles.label}>
|
||||||
|
System Instruction
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={systemInstruction}
|
||||||
|
onInput={setInstructionValue}
|
||||||
|
placeholder="Enter system instruction for the AI assistant..."
|
||||||
|
class={clsx(styles.input, styles.textarea)}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.footer}>
|
<div class={styles.footer}>
|
||||||
<button onClick={onClose} class={clsx(styles.button, styles.buttonSecondary)}>
|
<button onClick={onClose} class={clsx(styles.button, styles.buttonSecondary)}>
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import { useState } from "preact/hooks";
|
|
||||||
|
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
|
||||||
import { useAppState } from "../../contexts/state";
|
|
||||||
import styles from "../../assets/settings-modal.module.css";
|
|
||||||
import { X } from "lucide-preact";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BannedTokensSettings = ({ onClose }: Props) => {
|
|
||||||
const { bannedTokens, dispatch } = useAppState();
|
|
||||||
const [inputValue, setInputValue] = useInputState();
|
|
||||||
|
|
||||||
const sortedTokens = [...bannedTokens].sort((a, b) =>
|
|
||||||
a.trim().toLowerCase().localeCompare(b.trim().toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
const trimmed = inputValue.trim();
|
|
||||||
if (trimmed && !bannedTokens.includes(trimmed)) {
|
|
||||||
dispatch({ type: "SET_BANNED_TOKENS", tokens: [...bannedTokens, trimmed] });
|
|
||||||
setInputValue("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (token: string) => {
|
|
||||||
dispatch({ type: "SET_BANNED_TOKENS", tokens: bannedTokens.filter((t) => t !== token) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") handleAdd();
|
|
||||||
else if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class={styles.inputRow}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onInput={setInputValue}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Token to ban"
|
|
||||||
class={styles.input}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button onClick={handleAdd} class={clsx(styles.button, styles.buttonPrimary)}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class={styles.divider} />
|
|
||||||
<div class={styles.tokenList}>
|
|
||||||
{sortedTokens.length === 0 ? (
|
|
||||||
<p class={styles.emptyText}>No banned tokens</p>
|
|
||||||
) : (
|
|
||||||
sortedTokens.map((token) => (
|
|
||||||
<div key={token} class={styles.tokenItem}>
|
|
||||||
<span>{token}</span>
|
|
||||||
<button onClick={() => handleRemove(token)} class={styles.tokenRemoveButton}>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import { useMemo, useRef } from "preact/hooks";
|
|
||||||
|
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
|
||||||
import { useQuery } from "@common/hooks/useAsyncState";
|
|
||||||
import { useUpdate } from "@common/hooks/useUpdate";
|
|
||||||
import { useAppState } from "../../contexts/state";
|
|
||||||
import styles from "../../assets/settings-modal.module.css";
|
|
||||||
import LLM from "../../utils/llm";
|
|
||||||
|
|
||||||
export const ConnectionSettings = () => {
|
|
||||||
const { connection, model, dispatch } = useAppState();
|
|
||||||
const [url, setUrl] = useInputState(connection?.url ?? "");
|
|
||||||
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
|
|
||||||
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
|
|
||||||
const [update, triggerFetch] = useUpdate();
|
|
||||||
|
|
||||||
const urlRef = useRef(url);
|
|
||||||
const apiKeyRef = useRef(apiKey);
|
|
||||||
urlRef.current = url;
|
|
||||||
apiKeyRef.current = apiKey;
|
|
||||||
|
|
||||||
const connectionToFetch = useMemo<LLM.Connection | null>(() => {
|
|
||||||
const currentUrl = urlRef.current;
|
|
||||||
const currentApiKey = apiKeyRef.current;
|
|
||||||
if (!currentUrl || !currentApiKey) return null;
|
|
||||||
return { url: currentUrl, apiKey: currentApiKey };
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const fetchModels = useMemo(() => async (conn: LLM.Connection | null) => {
|
|
||||||
if (!conn) return [];
|
|
||||||
const r = await LLM.getModels(conn);
|
|
||||||
return r.data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const modelsData = useQuery(fetchModels, connectionToFetch);
|
|
||||||
const isLoadingModels = connectionToFetch != null && modelsData == undefined;
|
|
||||||
|
|
||||||
const groupedModels = useMemo(() => {
|
|
||||||
const sorted = (modelsData ?? []).sort((a, b) => {
|
|
||||||
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
|
|
||||||
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
|
|
||||||
if (aWeight !== bWeight) return bWeight - aWeight;
|
|
||||||
const aContext = a.max_context ?? 0;
|
|
||||||
const bContext = b.max_context ?? 0;
|
|
||||||
if (aContext !== bContext) return bContext - aContext;
|
|
||||||
return a.id.localeCompare(b.id);
|
|
||||||
});
|
|
||||||
const groups = Map.groupBy(sorted, m => m.max_context ?? 0);
|
|
||||||
return Array.from(groups.entries())
|
|
||||||
.sort((a, b) => b[0] - a[0])
|
|
||||||
.map(([context, models]) => ({ context, models }));
|
|
||||||
}, [modelsData]);
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
if (url && apiKey) {
|
|
||||||
dispatch({ type: "SET_CONNECTION", connection: { url, apiKey } });
|
|
||||||
triggerFetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && url && apiKey) {
|
|
||||||
dispatch({ type: "SET_CONNECTION", connection: { url, apiKey } });
|
|
||||||
triggerFetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelChange = (e: Event) => {
|
|
||||||
setSelectedModel(e);
|
|
||||||
const target = e.target as HTMLSelectElement;
|
|
||||||
const selectedModelInfo = modelsData?.find(m => m.id === target.value) ?? null;
|
|
||||||
dispatch({ type: "SET_MODEL", model: selectedModelInfo });
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectionToTest = url && apiKey ? { url, apiKey } : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.form} autocomplete="off">
|
|
||||||
<div class={styles.formGroup}>
|
|
||||||
<label class={styles.label}>API URL</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={url}
|
|
||||||
onInput={setUrl}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="http://localhost:1234"
|
|
||||||
class={styles.input}
|
|
||||||
autocomplete="off"
|
|
||||||
name="api-url-random"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class={styles.formGroup}>
|
|
||||||
<label class={styles.label}>API Key</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onInput={setApiKey}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="your-api-key"
|
|
||||||
class={styles.input}
|
|
||||||
autocomplete="new-password"
|
|
||||||
name="api-key-random"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class={styles.formGroup}>
|
|
||||||
<label class={styles.label}>Model</label>
|
|
||||||
{connectionToTest ? (
|
|
||||||
isLoadingModels ? (
|
|
||||||
<p>Loading models...</p>
|
|
||||||
) : groupedModels.length > 0 ? (
|
|
||||||
<select
|
|
||||||
value={selectedModel}
|
|
||||||
onChange={handleModelChange}
|
|
||||||
class={styles.select}
|
|
||||||
>
|
|
||||||
<option value="">Select a model</option>
|
|
||||||
{groupedModels.map(({ context, models }) => (
|
|
||||||
<optgroup key={context} label={`${context} context`}>
|
|
||||||
{models.map(m => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.support_tools ? '🔨' : ''}{m.support_thinking ? '🧠' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<p>No models available</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p>Enter connection details to load models</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import { useInputCallback } from "@common/hooks/useInputCallback";
|
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
|
||||||
import { highlight } from "@common/highlight";
|
|
||||||
import { useAppState } from "../../contexts/state";
|
|
||||||
import styles from "../../assets/settings-modal.module.css";
|
|
||||||
|
|
||||||
export const SystemInstructionSettings = () => {
|
|
||||||
const { systemInstruction, dispatch } = useAppState();
|
|
||||||
|
|
||||||
const setInstructionValue = useInputCallback((value) => {
|
|
||||||
dispatch({ type: "SET_SYSTEM_INSTRUCTION", systemInstruction: value });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.form}>
|
|
||||||
<div class={clsx(styles.formGroup, styles.formGroupFill)}>
|
|
||||||
<label class={styles.label}>System Instruction</label>
|
|
||||||
<ContentEditable
|
|
||||||
value={highlight(systemInstruction)}
|
|
||||||
onInput={setInstructionValue}
|
|
||||||
placeholder="Enter system instruction for the AI assistant..."
|
|
||||||
class={clsx(styles.input, styles.textarea)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue