Compare commits
3 Commits
469359807d
...
eed4f492cc
| Author | SHA1 | Date |
|---|---|---|
|
|
eed4f492cc | |
|
|
a4b8883473 | |
|
|
b1cab340ca |
|
|
@ -21,5 +21,5 @@ export async function getGames() {
|
|||
if (!stat.isDirectory()) return [];
|
||||
|
||||
const list = await fs.readdir(dir);
|
||||
return list.filter(d => d !== 'index');
|
||||
return list.filter(d => d !== 'index').sort();
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@huggingface/jinja": "0.3.1",
|
||||
"@inquirer/select": "2.3.10",
|
||||
"ace-builds": "1.36.3",
|
||||
"classnames": "2.5.1",
|
||||
"preact": "10.22.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
height: fit-content;
|
||||
max-height: 80dvh;
|
||||
overflow: hidden;
|
||||
background-color: var(--backgroundColorDark, #111);
|
||||
background-color: var(--backgroundColor, #333333);
|
||||
color: var(--color, white);
|
||||
border: var(--border, 1px solid white);
|
||||
outline: none;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useState } from "preact/hooks";
|
||||
|
||||
export const useBool = (initialValue: boolean) => {
|
||||
export const useBool = (initialValue: boolean = false) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const setTrue = useCallback(() => setValue(true), []);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { useCallback, useState } from "preact/hooks";
|
||||
|
||||
export const useInputState = (defaultValue = ''): [string, (e: Event | string) => void] => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
const handleInput = useCallback((e: Event | string) => {
|
||||
if (typeof e === 'string') {
|
||||
setValue(e);
|
||||
} else {
|
||||
const { target } = e;
|
||||
if (target && 'value' in target && typeof target.value === 'string') {
|
||||
setValue(target.value);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [value, handleInput];
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { useEffect, useState, type Ref } from "preact/hooks";
|
||||
|
||||
export const useIsVisible = (ref: Ref<HTMLElement>, onlyFirst = false) => {
|
||||
const [isVisible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setVisible(entry.isIntersecting);
|
||||
if (entry.isIntersecting && onlyFirst) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [ref.current, onlyFirst]);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 3.2 KiB |
|
|
@ -24,28 +24,20 @@
|
|||
textarea,
|
||||
input {
|
||||
color: var(--color);
|
||||
background-color: var(--backgroundColor);
|
||||
border: var(--border);
|
||||
background-color: var(--backgroundColorDark);
|
||||
font-size: 1em;
|
||||
font-family: sans-serif;
|
||||
background-color: transparent;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input {
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
resize: none;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 4px;
|
||||
|
||||
&.border {
|
||||
border: var(--border);
|
||||
}
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -59,6 +51,18 @@ button {
|
|||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.icon {
|
||||
font-family: var(--emojiFont);
|
||||
font-size: 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&.color {
|
||||
font-family: var(--emojiColorFont);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -71,6 +75,7 @@ body {
|
|||
flex-direction: row;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
|
|
@ -81,24 +86,6 @@ body {
|
|||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
|
||||
>.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
border: var(--border);
|
||||
|
||||
>input {
|
||||
&.valid {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
background-color: var(--red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -112,112 +99,27 @@ body {
|
|||
border: var(--border);
|
||||
border-bottom: none;
|
||||
border-top: none;
|
||||
|
||||
>.message {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
|
||||
&.role-user {
|
||||
background-color: var(--shadeColor);
|
||||
|
||||
&:not(.last-user) .content .text {
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.role-assistant {
|
||||
border-top: 1px solid var(--backgroundColorDark);
|
||||
}
|
||||
|
||||
>.content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
|
||||
>textarea {
|
||||
background-color: var(--backgroundColorDark);
|
||||
border: var(--border);
|
||||
min-height: 100px;
|
||||
height: unset;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
padding: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
>.text {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
animation-duration: 300ms;
|
||||
|
||||
>.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
>.italic {
|
||||
font-style: italic;
|
||||
color: var(--italicColor);
|
||||
}
|
||||
|
||||
>.quote {
|
||||
color: var(--quoteColor);
|
||||
}
|
||||
}
|
||||
|
||||
>.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
>.icon {
|
||||
font-family: var(--emojiFont);
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--color);
|
||||
cursor: pointer;
|
||||
|
||||
&.color {
|
||||
font-family: var(--emojiColorFont);
|
||||
}
|
||||
}
|
||||
|
||||
>.swipes {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
>div {
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.chat-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
border: var(--border);
|
||||
> textarea {
|
||||
min-height: 48px;
|
||||
resize: none;
|
||||
background-color: var(--backgroundColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ace_editor {
|
||||
background-color: var(--backgroundColorDark) !important;
|
||||
border: var(--border) !important;
|
||||
}
|
||||
|
||||
@keyframes swipe-from-left {
|
||||
0% {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
|
||||
import { useEffect, useMemo, useRef } from "preact/hooks";
|
||||
import ace from "ace-builds";
|
||||
import { useIsVisible } from "@common/hooks/useIsVisible";
|
||||
|
||||
import "ace-builds/src-noconflict/mode-django";
|
||||
import "ace-builds/src-noconflict/theme-terminal";
|
||||
|
||||
interface IAceProps {
|
||||
value: string;
|
||||
onInput: (e: InputEvent | string) => void;
|
||||
}
|
||||
|
||||
export const Ace = ({ value, onInput }: IAceProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useIsVisible(ref);
|
||||
|
||||
const editor = useMemo(() => {
|
||||
if (ref.current) {
|
||||
const e = ace.edit(ref.current, {
|
||||
theme: 'ace/theme/terminal',
|
||||
mode: 'ace/mode/django',
|
||||
showGutter: false,
|
||||
showPrintMargin: false,
|
||||
highlightActiveLine: false,
|
||||
displayIndentGuides: false,
|
||||
fontSize: 16,
|
||||
maxLines: Infinity,
|
||||
wrap: "free",
|
||||
});
|
||||
return e;
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
if (editor.getValue() !== value) {
|
||||
const pos = editor.getCursorPosition();
|
||||
editor.setValue(value);
|
||||
editor.selection.clearSelection();
|
||||
editor.moveCursorToPosition(pos);
|
||||
}
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onInput && editor) {
|
||||
const e = editor;
|
||||
const handler = () => onInput(e.getValue());
|
||||
|
||||
e.on('input', handler);
|
||||
return () => e.off('input', handler);
|
||||
}
|
||||
}, [editor, onInput]);
|
||||
|
||||
return (
|
||||
<div ref={ref} />
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Header } from "./header";
|
||||
import { Header } from "./header/header";
|
||||
import { Chat } from "./chat";
|
||||
import { Input } from "./input";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { JSX } from "preact/jsx-runtime"
|
||||
|
||||
import { useIsVisible } from '@common/hooks/useIsVisible';
|
||||
|
||||
export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) => {
|
||||
const { value } = props;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const isVisible = useIsVisible(ref, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const area = ref.current;
|
||||
|
||||
area.style.height = '0'; // reset
|
||||
area.style.height = `${area.scrollHeight}px`;
|
||||
}
|
||||
}, [value, isVisible]);
|
||||
|
||||
|
||||
|
||||
return <textarea {...props} ref={ref} />
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||
import { StateContext } from "../contexts/state";
|
||||
import { Message } from "./message";
|
||||
import { Message } from "./message/message";
|
||||
import { MessageTools } from "../messages";
|
||||
import { DOMTools } from "../dom";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
|
||||
import { StateContext } from "../contexts/state";
|
||||
import { LLMContext } from "../contexts/llm";
|
||||
|
||||
export const Header = () => {
|
||||
const llm = useContext(LLMContext);
|
||||
const { connectionUrl, setConnectionUrl } = useContext(StateContext);
|
||||
const [urlValid, setUrlValid] = useState(false);
|
||||
const [urlEditing, setUrlEditing] = useState(false);
|
||||
|
||||
const handleEditUrl = useCallback((e: InputEvent) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setConnectionUrl(e.target.value.trim());
|
||||
}
|
||||
}, [setConnectionUrl]);
|
||||
|
||||
const handleFocusUrl = useCallback(() => setUrlEditing(true), []);
|
||||
|
||||
const handleBlurUrl = useCallback(() => {
|
||||
const regex = /^(?:http(s?):\/\/)?(.*?)\/?$/i
|
||||
const normalizedConnectionUrl = connectionUrl.replace(regex, 'http$1://$2');
|
||||
console.log({ connectionUrl, normalizedConnectionUrl })
|
||||
setConnectionUrl(normalizedConnectionUrl);
|
||||
setUrlEditing(false);
|
||||
setUrlValid(false);
|
||||
}, [connectionUrl, setConnectionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!urlEditing) {
|
||||
llm.getContextLength().then(length => {
|
||||
setUrlValid(length > 0);
|
||||
});
|
||||
}
|
||||
}, [connectionUrl, urlEditing]);
|
||||
|
||||
return (
|
||||
<div class="header">
|
||||
<input value={connectionUrl}
|
||||
onInput={handleEditUrl}
|
||||
onFocus={handleFocusUrl}
|
||||
onBlur={handleBlurUrl}
|
||||
class={urlEditing ? '' : urlValid ? 'valid' : 'invalid'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
border: var(--border);
|
||||
|
||||
>input {
|
||||
&.valid {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
background-color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scrollPane {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
margin: 8px 0;
|
||||
|
||||
textarea {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { useCallback, useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useBool } from "@common/hooks/useBool";
|
||||
import { Modal } from "@common/components/modal/modal";
|
||||
|
||||
import { StateContext } from "../../contexts/state";
|
||||
import { LLMContext } from "../../contexts/llm";
|
||||
import { MiniChat } from "../minichat/minichat";
|
||||
import { AutoTextarea } from "../autoTextarea";
|
||||
|
||||
import styles from './header.module.css';
|
||||
import { Ace } from "../ace";
|
||||
|
||||
export const Header = () => {
|
||||
const { getContextLength } = useContext(LLMContext);
|
||||
const {
|
||||
messages, connectionUrl, systemPrompt, lore, userPrompt, bannedWords,
|
||||
setConnectionUrl, setSystemPrompt, setLore, setUserPrompt, addSwipe, setBannedWords,
|
||||
} = useContext(StateContext);
|
||||
const [urlValid, setUrlValid] = useState(false);
|
||||
const [urlEditing, setUrlEditing] = useState(false);
|
||||
|
||||
const loreOpen = useBool();
|
||||
const promptsOpen = useBool();
|
||||
const assistantOpen = useBool();
|
||||
|
||||
const bannedWordsInput = useMemo(() => bannedWords.join('\n'), [bannedWords]);
|
||||
|
||||
const handleFocusUrl = useCallback(() => setUrlEditing(true), []);
|
||||
|
||||
const handleBlurUrl = useCallback(() => {
|
||||
const regex = /^(?:http(s?):\/\/)?(.*?)\/?$/i
|
||||
const normalizedConnectionUrl = connectionUrl.replace(regex, 'http$1://$2');
|
||||
console.log({ connectionUrl, normalizedConnectionUrl })
|
||||
setConnectionUrl(normalizedConnectionUrl);
|
||||
setUrlEditing(false);
|
||||
setUrlValid(false);
|
||||
}, [connectionUrl, setConnectionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!urlEditing) {
|
||||
getContextLength().then(length => {
|
||||
setUrlValid(length > 0);
|
||||
});
|
||||
}
|
||||
}, [connectionUrl, urlEditing]);
|
||||
|
||||
const handleAssistantAddSwipe = useCallback((answer: string) => {
|
||||
const index = messages.findLastIndex(m => m.role === 'assistant');
|
||||
addSwipe(index, answer);
|
||||
assistantOpen.setFalse();
|
||||
}, [addSwipe, messages]);
|
||||
|
||||
const handleSetBannedWords = useCallback((e: Event) => {
|
||||
if (e.target instanceof HTMLTextAreaElement) {
|
||||
const words = e.target.value.split('\n');
|
||||
setBannedWords(words);
|
||||
}
|
||||
}, [setBannedWords]);
|
||||
|
||||
const handleBlurBannedWords = useCallback((e: Event) => {
|
||||
if (e.target instanceof HTMLTextAreaElement) {
|
||||
const words = e.target.value.split('\n').sort();
|
||||
setBannedWords(words);
|
||||
}
|
||||
}, [setBannedWords]);
|
||||
|
||||
return (
|
||||
<div class={styles.header}>
|
||||
<input value={connectionUrl}
|
||||
onInput={setConnectionUrl}
|
||||
onFocus={handleFocusUrl}
|
||||
onBlur={handleBlurUrl}
|
||||
class={urlEditing ? '' : urlValid ? styles.valid : styles.invalid} />
|
||||
<div class={styles.buttons}>
|
||||
<button class='icon color' title='Edit lore' onClick={loreOpen.setTrue}>
|
||||
🌍
|
||||
</button>
|
||||
<button class='icon color' title='Edit prompts' onClick={promptsOpen.setTrue}>
|
||||
📃
|
||||
</button>
|
||||
</div>
|
||||
<div class={styles.buttons}>
|
||||
<button class='icon' onClick={assistantOpen.setTrue} title='Ask assistant'>
|
||||
❓
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={loreOpen.value} onClose={loreOpen.setFalse}>
|
||||
<h3 class={styles.modalTitle}>Lore Editor</h3>
|
||||
<AutoTextarea value={lore} onInput={setLore} />
|
||||
</Modal>
|
||||
<Modal open={promptsOpen.value} onClose={promptsOpen.setFalse}>
|
||||
<h3 class={styles.modalTitle}>Prompts Editor</h3>
|
||||
<div className={styles.scrollPane}>
|
||||
<h4 class={styles.modalTitle}>System prompt</h4>
|
||||
<AutoTextarea value={systemPrompt} onInput={setSystemPrompt} />
|
||||
<hr />
|
||||
<h4 class={styles.modalTitle}>User prompt template</h4>
|
||||
<Ace value={userPrompt} onInput={setUserPrompt} />
|
||||
<hr />
|
||||
<h4 class={styles.modalTitle}>Banned phrases</h4>
|
||||
<AutoTextarea
|
||||
value={bannedWordsInput}
|
||||
onInput={handleSetBannedWords}
|
||||
onBlur={handleBlurBannedWords}
|
||||
class={styles.template}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<MiniChat
|
||||
history={messages}
|
||||
open={assistantOpen.value}
|
||||
onClose={assistantOpen.setFalse}
|
||||
buttons={{ 'Add swipe': handleAssistantAddSwipe }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,12 @@
|
|||
import { useCallback, useContext } from "preact/hooks";
|
||||
import { DOMTools } from "../dom";
|
||||
import { StateContext } from "../contexts/state";
|
||||
import { LLMContext } from "../contexts/llm";
|
||||
import { AutoTextarea } from "./autoTextarea";
|
||||
|
||||
export const Input = () => {
|
||||
const { input, setInput, addMessage, continueMessage } = useContext(StateContext);
|
||||
const { generating } = useContext(LLMContext);
|
||||
|
||||
console.log({generating});
|
||||
|
||||
const handleChange = useCallback((e: Event) => {
|
||||
if (e.target instanceof HTMLTextAreaElement) {
|
||||
setInput(e.target.value);
|
||||
}
|
||||
DOMTools.fixHeight(e.target);
|
||||
}, [setInput]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!generating) {
|
||||
const newInput = input.trim();
|
||||
|
|
@ -37,8 +28,8 @@ export const Input = () => {
|
|||
|
||||
return (
|
||||
<div class="chat-input">
|
||||
<textarea onInput={handleChange} onKeyDown={handleKeyDown} value={input} />
|
||||
<button onClick={handleSend} class={`${generating ? 'disabled': ''}`}>{input ? 'Send' : 'Continue'}</button>
|
||||
<AutoTextarea onInput={setInput} onKeyDown={handleKeyDown} value={input} />
|
||||
<button onClick={handleSend} class={`${generating ? 'disabled' : ''}`}>{input ? 'Send' : 'Continue'}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useMemo } from "preact/hooks";
|
||||
import { MessageTools } from "../../messages";
|
||||
|
||||
import styles from './message.module.css';
|
||||
|
||||
interface IFormattedMessageProps {
|
||||
children: string;
|
||||
class?: string;
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const FormattedMessage = ({ children, ['class']: cls, className }: IFormattedMessageProps) => {
|
||||
const __html = useMemo(() => MessageTools.format(children), [children]);
|
||||
|
||||
return <div
|
||||
style={{ whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{ __html }}
|
||||
class={`${cls ?? className ?? ''} ${styles.text}`}
|
||||
/>;
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
.message {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
|
||||
&.user {
|
||||
background-color: var(--shadeColor);
|
||||
|
||||
&:not(.lastUser) .content .text {
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
border-top: 1px solid var(--backgroundColorDark);
|
||||
}
|
||||
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
|
||||
>textarea {
|
||||
border: var(--border);
|
||||
min-height: 100px;
|
||||
height: unset;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
padding: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
.swipes {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
>div {
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
animation-duration: 300ms;
|
||||
|
||||
:global(.bold) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:global(.italic) {
|
||||
font-style: italic;
|
||||
color: var(--italicColor);
|
||||
}
|
||||
|
||||
:global(.quote) {
|
||||
color: var(--quoteColor);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { MessageTools, type IMessage } from "../messages";
|
||||
import { StateContext } from "../contexts/state";
|
||||
import { useBool } from "@common/hooks/useBool";
|
||||
import { MiniChat } from "./minichat/minichat";
|
||||
import { DOMTools } from "../dom";
|
||||
import { MessageTools, type IMessage } from "../../messages";
|
||||
import { StateContext } from "../../contexts/state";
|
||||
import { DOMTools } from "../../dom";
|
||||
|
||||
import styles from './message.module.css';
|
||||
import { AutoTextarea } from "../autoTextarea";
|
||||
|
||||
interface IProps {
|
||||
message: IMessage;
|
||||
|
|
@ -13,13 +14,11 @@ interface IProps {
|
|||
}
|
||||
|
||||
export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps) => {
|
||||
const { messages, editMessage, deleteMessage, setCurrentSwipe, addSwipe } = useContext(StateContext);
|
||||
const { editMessage, deleteMessage, setCurrentSwipe, addSwipe } = useContext(StateContext);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [savedMessage, setSavedMessage] = useState('');
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const assistantModalOpen = useBool(false);
|
||||
|
||||
const content = useMemo(() => MessageTools.getSwipe(message)?.content, [message]);
|
||||
const htmlContent = useMemo(() => MessageTools.format(content ?? ''), [content]);
|
||||
|
||||
|
|
@ -47,7 +46,6 @@ export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps)
|
|||
const newContent = e.target.value;
|
||||
editMessage(index, newContent);
|
||||
}
|
||||
DOMTools.fixHeight(e.target);
|
||||
}, [editMessage, index]);
|
||||
|
||||
const handleSwipeLeft = useCallback(() => {
|
||||
|
|
@ -60,52 +58,35 @@ export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps)
|
|||
DOMTools.animate(textRef.current, 'swipe-from-right');
|
||||
}, [setCurrentSwipe, index, message]);
|
||||
|
||||
|
||||
const handleAssistantAddSwipe = useCallback((answer: string) => {
|
||||
addSwipe(index, answer);
|
||||
assistantModalOpen.setFalse();
|
||||
}, [addSwipe, index]);
|
||||
|
||||
return (
|
||||
<div class={`message role-${message.role} ${isLastUser ? 'last-user' : ''}`}>
|
||||
<div class="content">
|
||||
<div class={`${styles.message} ${styles[message.role]} ${isLastUser ? styles.lastUser : ''}`}>
|
||||
<div class={styles.content}>
|
||||
{editing
|
||||
? <textarea onInput={handleEdit} value={content} class="edit-input" />
|
||||
: <div class="text" dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} />
|
||||
? <AutoTextarea onInput={handleEdit} value={content} />
|
||||
: <div class={styles.text} dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} />
|
||||
}
|
||||
{(isLastUser || message.role === 'assistant') &&
|
||||
<div class="buttons">
|
||||
<div class={styles.buttons}>
|
||||
{editing
|
||||
? <>
|
||||
<button class="icon" onClick={handleToggleEdit}>✔</button>
|
||||
<button class="icon" onClick={handleDeleteMessage}>🗑️</button>
|
||||
<button class="icon" onClick={handleCancelEdit}>❌</button>
|
||||
<button class='icon' onClick={handleToggleEdit}>✔</button>
|
||||
<button class='icon' onClick={handleDeleteMessage}>🗑️</button>
|
||||
<button class='icon' onClick={handleCancelEdit}>❌</button>
|
||||
</>
|
||||
: <>
|
||||
{isLastAssistant &&
|
||||
<div class="swipes">
|
||||
<div class={styles.swipes}>
|
||||
<div onClick={handleSwipeLeft}>◀</div>
|
||||
<div>{message.currentSwipe + 1}/{message.swipes.length}</div>
|
||||
<div onClick={handleSwipeRight}>▶</div>
|
||||
</div>
|
||||
}
|
||||
<button class="icon" onClick={handleToggleEdit} title="Edit">🖊</button>
|
||||
{isLastAssistant &&
|
||||
<button class="icon" onClick={assistantModalOpen.setTrue} title='Ask assistant'>
|
||||
❓
|
||||
</button>
|
||||
}
|
||||
<button class='icon' onClick={handleToggleEdit} title="Edit">🖊</button>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<MiniChat
|
||||
history={messages}
|
||||
open={assistantModalOpen.value}
|
||||
onClose={assistantModalOpen.setFalse}
|
||||
buttons={{ 'Add swipe': handleAssistantAddSwipe }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,6 +10,16 @@
|
|||
background-color: var(--shadeColor);
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.message {
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: hidden;
|
||||
min-height: 60px;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { DOMTools } from "../../dom";
|
|||
|
||||
import styles from './minichat.module.css';
|
||||
import { LLMContext } from "../../contexts/llm";
|
||||
import { FormattedMessage } from "../message/formattedMessage";
|
||||
import { AutoTextarea } from "../autoTextarea";
|
||||
|
||||
interface IProps {
|
||||
open: boolean;
|
||||
|
|
@ -14,7 +16,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps) => {
|
||||
const { generating, generate } = useContext(LLMContext);
|
||||
const { generating, generate, compilePrompt } = useContext(LLMContext);
|
||||
const [messages, setMessages] = useState<IMessage[]>([]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -23,10 +25,6 @@ export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps)
|
|||
[messages]
|
||||
);
|
||||
|
||||
const fitTextareas = useCallback((height = false) => {
|
||||
ref.current?.querySelectorAll('textarea').forEach(height ? DOMTools.fixHeight : DOMTools.scrollDown);
|
||||
}, []);
|
||||
|
||||
const handleInit = useCallback((force = false) => {
|
||||
if (force || confirm('Clear chat?')) {
|
||||
setMessages([MessageTools.create('', 'user', true)]);
|
||||
|
|
@ -34,8 +32,12 @@ export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps)
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fitTextareas();
|
||||
}, [messages]);
|
||||
DOMTools.scrollDown(ref.current, false);
|
||||
}, [generating, open]);
|
||||
|
||||
useEffect(() => {
|
||||
DOMTools.scrollDown(ref.current, false);
|
||||
}, [MessageTools.getSwipe(messages.at(-1))?.content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
|
|
@ -47,15 +49,14 @@ export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps)
|
|||
const handleGenerate = useCallback(async () => {
|
||||
if (messages.length > 0 && !generating) {
|
||||
const promptMessages: IMessage[] = [...history, ...messages];
|
||||
const { prompt } = await MessageTools.compilePrompt(promptMessages, { keepUsers: messages.length + 1 });
|
||||
const { prompt } = await compilePrompt(promptMessages, { keepUsers: messages.length + 1 });
|
||||
|
||||
let text = '';
|
||||
const messageId = messages.length;
|
||||
const newMessages = [...messages, MessageTools.create('', 'assistant', true)];
|
||||
setMessages(newMessages);
|
||||
|
||||
DOMTools.scrollDown(ref.current);
|
||||
for await (const chunk of generate(prompt, { max_length: 512 })) {
|
||||
for await (const chunk of generate(prompt)) {
|
||||
text += chunk;
|
||||
setMessages(MessageTools.updateContent(newMessages, messageId, text));
|
||||
}
|
||||
|
|
@ -64,7 +65,6 @@ export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps)
|
|||
...MessageTools.updateContent(newMessages, messageId, MessageTools.trimSentence(text)),
|
||||
MessageTools.create('', 'user', true),
|
||||
]);
|
||||
fitTextareas(true);
|
||||
MessageTools.playReady();
|
||||
}
|
||||
}, [messages, history, generating]);
|
||||
|
|
@ -80,7 +80,6 @@ export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps)
|
|||
if (e.target instanceof HTMLTextAreaElement) {
|
||||
setMessages(MessageTools.updateContent(messages, i, e.target.value));
|
||||
}
|
||||
DOMTools.fixHeight(e.target);
|
||||
}, [messages]);
|
||||
|
||||
if (!open) {
|
||||
|
|
@ -89,15 +88,19 @@ export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps)
|
|||
return (
|
||||
<Modal open onClose={onClose}>
|
||||
<div class={styles.minichat} ref={ref}>
|
||||
<div>
|
||||
<div class={styles.messages}>
|
||||
{messages.map((m, i) => (
|
||||
<textarea
|
||||
key={i}
|
||||
class={`border ${styles[m.role]}`}
|
||||
value={MessageTools.getSwipe(m)?.content ?? ''}
|
||||
onInput={(e) => handleChange(i, e)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
generating
|
||||
? <FormattedMessage key={i} class={`${styles[m.role]} ${styles.message}`}>
|
||||
{MessageTools.getSwipe(m)?.content ?? ''}
|
||||
</FormattedMessage>
|
||||
: <AutoTextarea
|
||||
key={i}
|
||||
class={styles[m.role]}
|
||||
value={MessageTools.getSwipe(m)?.content ?? ''}
|
||||
onInput={(e) => handleChange(i, e)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,204 +1 @@
|
|||
export const p = (strings: TemplateStringsArray, ...args: any[]) =>
|
||||
String.raw(strings, ...args).trim().replace(/^ +| +$/img, '')
|
||||
|
||||
export const SYSTEM_PROMPT = p`
|
||||
You are creative writer.
|
||||
Write a story based on the world description below.
|
||||
Make sure you're following the provided lore exactly and not making up impossible things.
|
||||
`.replace(/[ \r\n]+/ig, ' ');
|
||||
|
||||
export const WORLD_INFO = p`
|
||||
### General World description
|
||||
|
||||
Fess is a fictional fantasy world inhabited by diverse creatures and races. Its technological progress is roughly equivalent to Earth's medieval ages, featuring bows and arrows, horses as the primary mode of transport, and fortified cities.
|
||||
|
||||
### Ether kinetics
|
||||
|
||||
In this world, people refer to what others might call magic as ether kinetics - the special ability of living beings to consciously manipulate a specific energy known as ether, causing various effects. For simplicity, ether kinetics is often referred to as eth. To use it, person should construct an imaginary structure, called an ether circuit or ether weaving, using their mind's eye to visualize slightly glowing translucent paths in the air, which represent ether channels. Once the desired pattern is established, the user fills it with energy. Ether itself is invisible and intangible; nobody could see it or touch it directly. Ether kinetics is not magic, it is pure physics: you could apply force (e.g. moving the thing around) or pressure (e.g. compressing a gas) to a matter, no more than that. But in creative hands it's potential is endless! For example, if you compress air hard enough - it becomes hot and could set thing on fire. Or if you make air to move in one direction it makes a wind. But eth could be applied to anything material, not just air or other gases: levitating objects or moving liquids around also possible. Objects could not contain ether or be infused with it.
|
||||
Eth users are called etherkins, or etherkin in singular form.
|
||||
Fess does not have any supernatural activity: souls, ghosts or similar mystical creatures are impossible by the laws of the world.
|
||||
|
||||
In summary:
|
||||
- Ether is and invisible and intangible energy.
|
||||
- Ether kinetics is manipulation of ether.
|
||||
- Eth is short name for ether kinetics.
|
||||
- Etherkin is the person who uses ether kinetics.
|
||||
- No supernatural activity.
|
||||
- Ether is pretty weak, not capable of massive things like manipulating the reality, illusions or teleportation.
|
||||
- It has absolutely no influence on minds and could not control them.
|
||||
- Objects could not be infused with it, nor contain it in any form.
|
||||
|
||||
### Races
|
||||
|
||||
The people of Fess are divided into three races:
|
||||
|
||||
**Ida**:
|
||||
The vast majority of the population, analogous to Earth's humans. They possess no special abilities or advantages and are average in all respects. Important: don't call them "humans", because word "human" in this world could be applied to any race, including neka and folia. Replace "human" with "ida". They have many kingdoms of different size on the both sides of the Median Ridge.
|
||||
|
||||
**Neka**:
|
||||
Members of this race possess certain animal traits such as animal ears or tails, though not always. They exhibit great physical strength, agility, and stamina. Nearly all of them were wiped out during the racist hunts over a thousand years ago. The surviving population now resides exclusively within Nordille, the largest city of the former neka kingdom. Occasionally, a few individual neka can be found traveling outside their city. Almost all neka are extremely devout towards Maya, mentioning her in nearly every conversation due to her saving their kind from extinction.
|
||||
|
||||
**Folia**:
|
||||
Similar to high elves in popular culture, members of the folia race possess long ears, blonde or white hair, and are skilled in utilizing eth. They are often found dwelling in lush, verdant forests, where their keen senses and natural agility allow them to traverse the wilderness with ease. Folia are renowned for their archery skills, preferring to use bows crafted from the finest woods found in their forest homes. Their keen eyesight and steady hands make them formidable marksmen, capable of hitting targets with precision from great distances. In addition to their combat prowess, folia are also known for their wisdom and deep connection to nature. They often serve as guardians and caretakers of the forests, using their knowledge of plants and animals to maintain the delicate balance of their ecosystems. Folia are generally peaceful and prefer to avoid conflicts, but when threatened, they can be fierce defenders of their homes and loved ones. They have two countries: North and South Folia, both located in the dense forests at the south-west of the continent.
|
||||
|
||||
In a general sense, the words "human" or "people" can apply to any race in this world.
|
||||
|
||||
### Geography
|
||||
|
||||
Alongside Nordille, there are several notable geographical features:
|
||||
|
||||
**Trinity**:
|
||||
Nestled in the middle of the continent, Trinity stands as a beacon of unity and cooperation among the races. This independent city-state is a harmonious blend of neka, ida, and folia cultures, with representatives from each race playing an active role in its governance. The city's architecture reflects this diversity, with neka-inspired stone structures, folia-influenced wooden buildings, and ida-created metalwork all coexisting in a beautiful tapestry of design.
|
||||
Trinity's streets are a vibrant mix of races, with neka and folia merchants hawking their wares alongside human craftsmen. The city's central plaza is home to a grand statue of Maya, symbolizing the unity and cooperation among the races. Here, festivals and celebrations are held throughout the year, bringing the people of Trinity together in a joyous display of their shared humanity.
|
||||
|
||||
**The Eternal Desert**:
|
||||
Stretching across the equator of Fess like a vast, shimmering ribbon, the Eternal Desert is a formidable barrier that has long separated the known world from the mysteries of the southern hemisphere. Its endless dunes of golden sand, scorching heat, and lack of water have made it virtually impassable for centuries, earning it the reputation of an uncrossable wasteland. The few brave souls who have attempted to traverse its treacherous expanse have vanished without a trace, their fates lost to the unforgiving sands. As a result, the civilizations of Fess have flourished primarily in the northern regions, leaving the secrets of the southern lands to remain shrouded in legends.
|
||||
|
||||
**Median Ridge**:
|
||||
Towering majestically above the landscape, the Median Ridge is a colossal mountain range that stretches across the east of the continent like a jagged spine. Its towering peaks, shrouded in perpetual snow and ice, create an imposing barrier that divides the land into two distinct halves. The rugged terrain and extreme weather conditions make traversing the ridge a daunting task, limiting communication and trade between the isolated communities on the either sides of the mountains.
|
||||
|
||||
### History
|
||||
|
||||
Fess was created about 1500 years ago by Maya. It was created in the same geological and cultural state as it is now, not accounting for minor changes. People of Fess speak the same common language that was given to them upon creation.
|
||||
|
||||
### Maya
|
||||
|
||||
Maya created the world of Fess around 1500 years ago as an imaginative playground, imbuing it with a rich history, diverse races, and an ether kinesis system. As the world's creator, Maya has ultimate control over the realm and its inhabitants, but she doesn't think of herself as a traditional goddess figure. Her existence is widely accepted and acknowledged among Fess's inhabitants, who view her as a beloved and protective creator. She's living in that same world in a human form.
|
||||
Maya looks like a neka with a pair of wolf ears, black hair and a tail with white tips. On her right hand exists an intricate geometric tattoo, resembling an ether circuit with interwoven lines of varying thickness, that stretches from her palm all the way up to her shoulder, glowing with a vibrant cyan color. Her eyes are a deep blue. She wears a futuristic sleeveless bodysuit and a pair of similar looking pants. Maya is very self-assured and enjoys embarrassing villains. She has no weapons, because she doesn't need them.
|
||||
Nobody recognizes her in her human form and treats just as strange clothed neka, though.
|
||||
Her intentions in this world is simple: to enjoy food, to learn something new, maybe kick some asses of bad guys, and to get fun in general.
|
||||
As the creator, Maya has complete knowledge of Fess's history, geography, and the fundamental laws that govern its existence. She is aware of all the stories, secrets, and potential plot lines that could unfold within her creation. She has no hidden origins or secrets that she needs to uncover, as she is the one who created those aspects in the first place. Maya has no fears, and not afraid of anything, because she is an immortal being.
|
||||
|
||||
`;
|
||||
|
||||
export const START_PROMPT = p`
|
||||
Write a novel using information above as a reference. Make sure to follow the lore exactly and avoid cliffhangers.
|
||||
`;
|
||||
|
||||
export const CONTINUE_PROPMT = (prompt?: string, isStart = false) =>
|
||||
prompt?.trim()
|
||||
? p`
|
||||
${isStart ? 'Start' : 'Continue'} this story, taking information into account: ${prompt.trim()}
|
||||
|
||||
Remember that this story should be infinite and go forever. Avoid cliffhangers and pauses, be creative.
|
||||
`
|
||||
: `Continue the story forward. Avoid cliffhangers and pauses.`;
|
||||
|
||||
export const LLAMA_TEMPLATE = `{% for message in messages %}{% set content = '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n'+ message['content'] | trim + '<|eot_id|>' %}{{ content }}{% endfor %}{% if add_generation_prompt %}{{ '<|start_header_id|>assistant<|end_header_id|>\n\n' }}{% endif %}`;
|
||||
|
||||
export const BANNED_WORDS = [
|
||||
" await",
|
||||
" lie ahead",
|
||||
" lies ahead",
|
||||
" lay ahead",
|
||||
" lays ahead",
|
||||
"a testament to",
|
||||
"anticipat",
|
||||
"journey",
|
||||
"voyage ",
|
||||
"heart race",
|
||||
"heart racing",
|
||||
"mind race",
|
||||
"mind racing",
|
||||
"minds racing",
|
||||
"minds race",
|
||||
"Would you like that",
|
||||
"What do you say",
|
||||
"possibilities",
|
||||
"predator",
|
||||
" explor",
|
||||
" intimate",
|
||||
" bond",
|
||||
" desire",
|
||||
".desire",
|
||||
" biting",
|
||||
" bites her plump lower lip",
|
||||
" bites her lower lip",
|
||||
" bites her lip",
|
||||
" bit her lower lip",
|
||||
" bit her lip",
|
||||
" bites his plump lower lip",
|
||||
" bites his lower lip",
|
||||
" bites his lip",
|
||||
" bit his lip",
|
||||
"barely above a whisper",
|
||||
"barely audible",
|
||||
"barely a whisper",
|
||||
" vulnerab",
|
||||
" shiver",
|
||||
" chill run",
|
||||
" chill down",
|
||||
" chill up",
|
||||
"sparkling with mischief",
|
||||
"tracing patterns",
|
||||
"traced patterns",
|
||||
"idly traces patterns",
|
||||
" air is thick ",
|
||||
" air was thick ",
|
||||
" air thick ",
|
||||
"aldric",
|
||||
"elara",
|
||||
"aedric",
|
||||
"zephyr",
|
||||
"lyra",
|
||||
"timmy",
|
||||
" indulg",
|
||||
" embrac",
|
||||
" hot and bothered",
|
||||
"sex",
|
||||
"manhood",
|
||||
"boyhood",
|
||||
"taboo",
|
||||
"spill the beans",
|
||||
"with need",
|
||||
"slick folds",
|
||||
"sensitive folds",
|
||||
"glistening folds",
|
||||
"wet folds",
|
||||
"swollen folds",
|
||||
"her folds",
|
||||
"my folds",
|
||||
"slippery folds",
|
||||
"claim me as your",
|
||||
"screaming your name",
|
||||
"scream your name",
|
||||
"awaits your next command",
|
||||
"awaits your next move",
|
||||
"waits for your next move",
|
||||
"plump bottom lip",
|
||||
"plump lip",
|
||||
"claim",
|
||||
" carnal",
|
||||
" primal",
|
||||
"...",
|
||||
"…",
|
||||
"throbbing",
|
||||
" a mix of ",
|
||||
" a blend of ",
|
||||
"camaraderie",
|
||||
"What do you think",
|
||||
"boundaries",
|
||||
" tapestr",
|
||||
"dynamic",
|
||||
"I see,",
|
||||
"I see.",
|
||||
" mournful ",
|
||||
"voluptuous",
|
||||
"eerie",
|
||||
" ye ",
|
||||
" ya ",
|
||||
].sort();
|
||||
|
||||
export const GENERATION_SETTINGS = {
|
||||
temperature: 0.8,
|
||||
min_p: 0.1,
|
||||
rep_pen: 1.08,
|
||||
rep_pen_range: -1,
|
||||
rep_pen_slope: 0.7,
|
||||
top_k: 100,
|
||||
top_p: 0.92,
|
||||
banned_tokens: BANNED_WORDS,
|
||||
max_length: 200,
|
||||
trim_stop: true,
|
||||
stop_sequence: ['[INST]', '[/INST]', '</s>', '<|']
|
||||
}
|
||||
|
|
@ -1,30 +1,172 @@
|
|||
import Lock from "@common/lock";
|
||||
import SSE from "@common/sse";
|
||||
import { GENERATION_SETTINGS } from "../const";
|
||||
import { LLAMA_TEMPLATE } from "../const";
|
||||
import { createContext } from "preact";
|
||||
import { useContext, useEffect, useMemo } from "preact/hooks";
|
||||
import { MessageTools } from "../messages";
|
||||
import { MessageTools, type IMessage } from "../messages";
|
||||
import { StateContext } from "./state";
|
||||
import { useBool } from "@common/hooks/useBool";
|
||||
import { Template } from "@huggingface/jinja";
|
||||
|
||||
|
||||
interface ITemplateMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ICompileArgs {
|
||||
keepUsers?: number;
|
||||
}
|
||||
|
||||
interface ICompiledPrompt {
|
||||
prompt: string;
|
||||
isContinue: boolean;
|
||||
isRegen: boolean;
|
||||
}
|
||||
|
||||
interface IContext {
|
||||
generating: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_GENERATION_SETTINGS = {
|
||||
temperature: 0.8,
|
||||
min_p: 0.1,
|
||||
rep_pen: 1.08,
|
||||
rep_pen_range: -1,
|
||||
rep_pen_slope: 0.7,
|
||||
top_k: 100,
|
||||
top_p: 0.92,
|
||||
banned_tokens: [],
|
||||
max_length: 512,
|
||||
trim_stop: true,
|
||||
stop_sequence: ['[INST]', '[/INST]', '</s>', '<|']
|
||||
}
|
||||
|
||||
type IGenerationSettings = Partial<typeof DEFAULT_GENERATION_SETTINGS>;
|
||||
|
||||
interface IActions {
|
||||
generate: (prompt: string, extraSettings?: Partial<typeof GENERATION_SETTINGS>) => AsyncGenerator<string>;
|
||||
applyChatTemplate: (messages: ITemplateMessage[], templateString: string, eosToken?: string) => string;
|
||||
compilePrompt: (messages: IMessage[], args?: ICompileArgs) => Promise<ICompiledPrompt>;
|
||||
generate: (prompt: string, extraSettings?: IGenerationSettings) => AsyncGenerator<string>;
|
||||
countTokens(prompt: string): Promise<number>;
|
||||
getContextLength(): Promise<number>;
|
||||
|
||||
}
|
||||
export type ILLMContext = IContext & IActions;
|
||||
|
||||
export const LLMContext = createContext<ILLMContext>({} as ILLMContext);
|
||||
|
||||
export const LLMContextProvider = ({ children }: { children?: any }) => {
|
||||
const { connectionUrl, messages, triggerNext, setTriggerNext, addMessage, editMessage } = useContext(StateContext);
|
||||
const {
|
||||
connectionUrl, messages, triggerNext, lore, userPrompt, systemPrompt, bannedWords,
|
||||
setTriggerNext, addMessage, editMessage,
|
||||
} = useContext(StateContext);
|
||||
const generating = useBool(false);
|
||||
|
||||
const userPromptTemplate = useMemo(() => {
|
||||
try {
|
||||
return new Template(userPrompt)
|
||||
} catch {
|
||||
return {
|
||||
render: () => userPrompt,
|
||||
}
|
||||
}
|
||||
}, [userPrompt]);
|
||||
|
||||
const actions: IActions = useMemo(() => ({
|
||||
applyChatTemplate: (messages: ITemplateMessage[], templateString: string, eosToken = '</s>') => {
|
||||
const template = new Template(templateString);
|
||||
|
||||
const prompt = template.render({
|
||||
messages,
|
||||
bos_token: '',
|
||||
eos_token: eosToken,
|
||||
add_generation_prompt: true,
|
||||
});
|
||||
|
||||
return prompt;
|
||||
},
|
||||
compilePrompt: async (messages, { keepUsers } = {}) => {
|
||||
const promptMessages = messages.slice();
|
||||
const lastMessage = promptMessages.at(-1);
|
||||
const isAssistantLast = lastMessage?.role === 'assistant';
|
||||
const isRegen = isAssistantLast && !MessageTools.getSwipe(lastMessage)?.content;
|
||||
const isContinue = isAssistantLast && !isRegen;
|
||||
|
||||
const userMessages = promptMessages.filter(m => m.role === 'user');
|
||||
const lastUserMessage = userMessages.at(-1);
|
||||
const firstUserMessage = userMessages.at(0);
|
||||
|
||||
if (isContinue) {
|
||||
promptMessages.push(MessageTools.create(userPromptTemplate.render({})));
|
||||
}
|
||||
|
||||
const system = `${systemPrompt}\n\n${lore}`.trim();
|
||||
|
||||
const templateMessages: ITemplateMessage[] = [
|
||||
{ role: 'system', content: system },
|
||||
];
|
||||
|
||||
if (keepUsers) {
|
||||
let usersRemaining = messages.filter(m => m.role === 'user').length;
|
||||
let wasStory = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const { role } = message;
|
||||
const content = MessageTools.getSwipe(message)?.content ?? '';
|
||||
if (role === 'user' && usersRemaining > keepUsers) {
|
||||
usersRemaining--;
|
||||
} else if (role === 'assistant' && templateMessages.at(-1).role === 'assistant') {
|
||||
wasStory = true;
|
||||
templateMessages.at(-1).content += '\n\n' + content;
|
||||
} else if (role === 'user' && !message.technical) {
|
||||
templateMessages.push({
|
||||
role: message.role,
|
||||
content: userPromptTemplate.render({ prompt: content, isStart: !wasStory }),
|
||||
});
|
||||
} else {
|
||||
if (role === 'assistant') {
|
||||
wasStory = true;
|
||||
}
|
||||
templateMessages.push({ role, content });
|
||||
}
|
||||
}
|
||||
|
||||
if (templateMessages[1]?.role !== 'user') {
|
||||
const prompt = MessageTools.getSwipe(firstUserMessage)?.content;
|
||||
|
||||
templateMessages.splice(1, 0, {
|
||||
role: 'user',
|
||||
content: userPromptTemplate.render({ prompt, isStart: true }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const story = promptMessages.filter(m => m.role === 'assistant')
|
||||
.map(m => MessageTools.getSwipe(m)?.content.trim()).join('\n\n');
|
||||
|
||||
if (story.length > 0) {
|
||||
const prompt = MessageTools.getSwipe(firstUserMessage)?.content;
|
||||
templateMessages.push({ role: 'user', content: userPromptTemplate.render({ prompt, isStart: true }) });
|
||||
templateMessages.push({ role: 'assistant', content: story });
|
||||
}
|
||||
|
||||
let userPrompt = MessageTools.getSwipe(lastUserMessage)?.content;
|
||||
if (!lastUserMessage?.technical && !isContinue && userPrompt) {
|
||||
userPrompt = userPromptTemplate.render({ prompt: userPrompt, isStart: story.length === 0 });
|
||||
}
|
||||
|
||||
if (userPrompt) {
|
||||
templateMessages.push({ role: 'user', content: userPrompt });
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = actions.applyChatTemplate(templateMessages, LLAMA_TEMPLATE);
|
||||
return {
|
||||
prompt,
|
||||
isContinue,
|
||||
isRegen,
|
||||
};
|
||||
},
|
||||
generate: async function* (prompt, extraSettings = {}) {
|
||||
if (!connectionUrl) {
|
||||
return;
|
||||
|
|
@ -36,7 +178,8 @@ export const LLMContextProvider = ({ children }: { children?: any }) => {
|
|||
|
||||
const sse = new SSE(`${connectionUrl}/api/extra/generate/stream`, {
|
||||
payload: JSON.stringify({
|
||||
...GENERATION_SETTINGS,
|
||||
...DEFAULT_GENERATION_SETTINGS,
|
||||
banned_tokens: bannedWords.filter(w => w.trim()),
|
||||
...extraSettings,
|
||||
prompt,
|
||||
}),
|
||||
|
|
@ -110,7 +253,7 @@ export const LLMContextProvider = ({ children }: { children?: any }) => {
|
|||
|
||||
return 0;
|
||||
},
|
||||
getContextLength: async() => {
|
||||
getContextLength: async () => {
|
||||
if (!connectionUrl) {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -126,7 +269,7 @@ export const LLMContextProvider = ({ children }: { children?: any }) => {
|
|||
|
||||
return 0;
|
||||
},
|
||||
}), [connectionUrl]);
|
||||
}), [connectionUrl, lore, userPromptTemplate, systemPrompt, bannedWords]);
|
||||
|
||||
useEffect(() => void (async () => {
|
||||
if (triggerNext && !generating.value) {
|
||||
|
|
@ -135,7 +278,7 @@ export const LLMContextProvider = ({ children }: { children?: any }) => {
|
|||
let messageId = messages.length - 1;
|
||||
let text: string = '';
|
||||
|
||||
const { prompt, isRegen } = await MessageTools.compilePrompt(messages);
|
||||
const { prompt, isRegen } = await actions.compilePrompt(messages);
|
||||
|
||||
if (!isRegen) {
|
||||
addMessage('', 'assistant');
|
||||
|
|
@ -155,7 +298,7 @@ export const LLMContextProvider = ({ children }: { children?: any }) => {
|
|||
})(), [triggerNext, messages, generating.value]);
|
||||
|
||||
const rawContext: IContext = {
|
||||
generating: generating.value,
|
||||
generating: generating.value,
|
||||
};
|
||||
|
||||
const context = useMemo(() => rawContext, Object.values(rawContext));
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import { createContext } from "preact";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { MessageTools, type IMessage } from "../messages";
|
||||
import { useInputState } from "@common/hooks/useInputState";
|
||||
|
||||
interface IContext {
|
||||
connectionUrl: string;
|
||||
input: string;
|
||||
name: string;
|
||||
lore: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
bannedWords: string[];
|
||||
messages: IMessage[];
|
||||
triggerNext: boolean;
|
||||
}
|
||||
|
||||
interface IActions {
|
||||
setConnectionUrl: (url: string) => void;
|
||||
setInput: (url: string) => void;
|
||||
setName: (name: string) => void;
|
||||
setConnectionUrl: (url: string | Event) => void;
|
||||
setInput: (url: string | Event) => void;
|
||||
setLore: (lore: string | Event) => void;
|
||||
setSystemPrompt: (prompt: string | Event) => void;
|
||||
setUserPrompt: (prompt: string | Event) => void;
|
||||
setBannedWords: (words: string[]) => void;
|
||||
setTriggerNext: (triggerNext: boolean) => void;
|
||||
|
||||
setMessages: (messages: IMessage[]) => void;
|
||||
|
|
@ -29,17 +36,21 @@ interface IActions {
|
|||
const SAVE_KEY = 'ai_game_save_state';
|
||||
|
||||
export const saveContext = (context: IContext) => {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify({
|
||||
...context,
|
||||
triggerNext: false,
|
||||
}));
|
||||
const contextToSave: Partial<IContext> = { ...context };
|
||||
delete contextToSave.triggerNext;
|
||||
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(contextToSave));
|
||||
}
|
||||
|
||||
export const loadContext = (): IContext => {
|
||||
const defaultContext: IContext = {
|
||||
connectionUrl: 'http://192.168.10.102:5001',
|
||||
input: '',
|
||||
name: 'Maya',
|
||||
systemPrompt: 'You are creative writer. Write a story based on the world description below.',
|
||||
lore: '',
|
||||
userPrompt: `{% if prompt %}{% if isStart %}Start{% else %}Continue{% endif %} this story, taking information into account: {{ prompt | trim }}
|
||||
Remember that this story should be infinite and go forever. Avoid cliffhangers and pauses, be creative.{% elif isStart %}Write a novel using information above as a reference. Make sure to follow the lore exactly and avoid cliffhangers.{% else %}Continue the story forward. Avoid cliffhangers and pauses.{% endif %}`,
|
||||
bannedWords: [],
|
||||
messages: [],
|
||||
triggerNext: false,
|
||||
};
|
||||
|
|
@ -62,17 +73,24 @@ export const StateContext = createContext<IStateContext>({} as IStateContext);
|
|||
|
||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||
const loadedContext = useMemo(() => loadContext(), []);
|
||||
const [connectionUrl, setConnectionUrl] = useState(loadedContext.connectionUrl);
|
||||
const [input, setInput] = useState(loadedContext.input);
|
||||
const [name, setName] = useState(loadedContext.name);
|
||||
const [connectionUrl, setConnectionUrl] = useInputState(loadedContext.connectionUrl);
|
||||
const [input, setInput] = useInputState(loadedContext.input);
|
||||
const [lore, setLore] = useInputState(loadedContext.lore);
|
||||
const [systemPrompt, setSystemPrompt] = useInputState(loadedContext.systemPrompt);
|
||||
const [userPrompt, setUserPrompt] = useInputState(loadedContext.userPrompt);
|
||||
const [bannedWords, setBannedWords] = useState<string[]>(loadedContext.bannedWords);
|
||||
const [messages, setMessages] = useState(loadedContext.messages);
|
||||
|
||||
const [triggerNext, setTriggerNext] = useState(false);
|
||||
|
||||
const actions: IActions = useMemo(() => ({
|
||||
setConnectionUrl,
|
||||
setInput,
|
||||
setName,
|
||||
setSystemPrompt,
|
||||
setUserPrompt,
|
||||
setLore,
|
||||
setTriggerNext,
|
||||
setBannedWords: (words) => setBannedWords([...words]),
|
||||
|
||||
setMessages: (newMessages) => setMessages(newMessages.slice()),
|
||||
addMessage: (content, role, triggerNext = false) => {
|
||||
|
|
@ -144,7 +162,10 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
|||
const rawContext: IContext = {
|
||||
connectionUrl,
|
||||
input,
|
||||
name,
|
||||
systemPrompt,
|
||||
lore,
|
||||
userPrompt,
|
||||
bannedWords,
|
||||
messages,
|
||||
triggerNext,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
export namespace DOMTools {
|
||||
export const fixHeight = (e: unknown) => {
|
||||
if (e instanceof HTMLElement) {
|
||||
e.style.height = '0'; // reset
|
||||
e.style.height = `${e.scrollHeight + 10}px`;
|
||||
}
|
||||
}
|
||||
|
||||
export const animate = (e: unknown, animationName: string) => {
|
||||
if (e instanceof Event) {
|
||||
e = e.target;
|
||||
}
|
||||
if (e instanceof HTMLElement) {
|
||||
e.style.animationName = '';
|
||||
e.style.animationName = animationName;
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollDown = (e: unknown) => {
|
||||
export const scrollDown = (e: unknown, smooth?: any) => {
|
||||
if (e instanceof Event) {
|
||||
e = e.target;
|
||||
}
|
||||
if (e instanceof HTMLElement) {
|
||||
e.scrollTo({
|
||||
top: e.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
behavior: smooth !== false ? 'smooth' : 'instant',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Template } from "@huggingface/jinja";
|
||||
import { LLAMA_TEMPLATE, p, SYSTEM_PROMPT, CONTINUE_PROPMT, WORLD_INFO, START_PROMPT } from "./const";
|
||||
import messageSound from './assets/message.mp3';
|
||||
|
||||
export interface ISwipe {
|
||||
|
|
@ -13,11 +12,6 @@ export interface IMessage {
|
|||
technical?: boolean;
|
||||
}
|
||||
|
||||
interface ITemplateMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export namespace MessageTools {
|
||||
export const getSwipe = (message?: IMessage | null) => message?.swipes[message?.currentSwipe];
|
||||
export const create = (content: string, role: IMessage['role'] = 'user', technical = false): IMessage => (
|
||||
|
|
@ -29,99 +23,6 @@ export namespace MessageTools {
|
|||
messageSound.play();
|
||||
}
|
||||
|
||||
export const applyChatTemplate = (messages: ITemplateMessage[], templateString: string, eosToken = '</s>') => {
|
||||
const template = new Template(templateString);
|
||||
|
||||
const prompt = template.render({
|
||||
messages,
|
||||
bos_token: '',
|
||||
eos_token: eosToken,
|
||||
add_generation_prompt: true,
|
||||
});
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
interface ICompileArgs {
|
||||
keepUsers?: number;
|
||||
}
|
||||
|
||||
interface ICompiledPrompt {
|
||||
prompt: string;
|
||||
isContinue: boolean;
|
||||
isRegen: boolean;
|
||||
}
|
||||
|
||||
export const compilePrompt = async (messages: IMessage[], { keepUsers }: ICompileArgs = {}): Promise<ICompiledPrompt> => {
|
||||
const promptMessages = messages.slice();
|
||||
const lastMessage = promptMessages.at(-1);
|
||||
const isAssistantLast = lastMessage?.role === 'assistant';
|
||||
const isRegen = isAssistantLast && !getSwipe(lastMessage)?.content;
|
||||
const isContinue = isAssistantLast && !isRegen;
|
||||
|
||||
if (isContinue) {
|
||||
promptMessages.push(create(CONTINUE_PROPMT()));
|
||||
}
|
||||
|
||||
const system = `${SYSTEM_PROMPT}\n\n${WORLD_INFO}`.trim();
|
||||
|
||||
const templateMessages: ITemplateMessage[] = [
|
||||
{ role: 'system', content: system },
|
||||
];
|
||||
|
||||
if (keepUsers) {
|
||||
let usersRemaining = messages.filter(m => m.role === 'user').length;
|
||||
let wasStory = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const { role } = message;
|
||||
const content = getSwipe(message)?.content ?? '';
|
||||
if (role === 'user' && usersRemaining > keepUsers) {
|
||||
usersRemaining--;
|
||||
} else if (role === 'assistant' && templateMessages.at(-1).role === 'assistant') {
|
||||
wasStory = true;
|
||||
templateMessages.at(-1).content += '\n\n' + content;
|
||||
} else if (role === 'user' && !message.technical) {
|
||||
templateMessages.push({ role: message.role, content: CONTINUE_PROPMT(content, !wasStory) });
|
||||
} else {
|
||||
if (role === 'assistant') {
|
||||
wasStory = true;
|
||||
}
|
||||
templateMessages.push({ role, content });
|
||||
}
|
||||
}
|
||||
|
||||
if (templateMessages[1]?.role !== 'user') {
|
||||
templateMessages.splice(1, 0, { role: 'user', content: START_PROMPT });
|
||||
}
|
||||
} else {
|
||||
const story = promptMessages.filter(m => m.role === 'assistant').map(m => getSwipe(m)?.content.trim()).join('\n\n');
|
||||
const userMessages = promptMessages.filter(m => m.role === 'user');
|
||||
const lastUserMessage = userMessages.at(-1);
|
||||
|
||||
let userPrompt = getSwipe(lastUserMessage)?.content;
|
||||
if (!lastUserMessage?.technical && !isContinue && userPrompt) {
|
||||
userPrompt = CONTINUE_PROPMT(userPrompt, story.length === 0);
|
||||
}
|
||||
|
||||
if (story.length > 0) {
|
||||
templateMessages.push({ role: 'user', content: START_PROMPT });
|
||||
templateMessages.push({ role: 'assistant', content: story });
|
||||
}
|
||||
|
||||
if (userPrompt) {
|
||||
templateMessages.push({ role: 'user', content: userPrompt });
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = applyChatTemplate(templateMessages, LLAMA_TEMPLATE);
|
||||
return {
|
||||
prompt,
|
||||
isContinue,
|
||||
isRegen,
|
||||
};
|
||||
}
|
||||
|
||||
export const format = (message: string): string => {
|
||||
const replaceRegex = /([*"]\*?)/ig;
|
||||
const splitToken = '___SPLIT_AWOORWA___';
|
||||
|
|
|
|||
Loading…
Reference in New Issue