1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Pabloader d917c6e6a9 AIStory: minichat 2024-11-01 21:11:48 +00:00
Pabloader fa77bb2339 AIStory: swipes 2024-11-01 15:28:26 +00:00
21 changed files with 947 additions and 415 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,42 @@
.dialog {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: 100%;
max-width: 1000px;
height: fit-content;
max-height: 80dvh;
overflow: hidden;
background-color: var(--backgroundColorDark, #111);
color: var(--color, white);
border: var(--border, 1px solid white);
outline: none;
padding: 0;
border-radius: var(--border-radius, 0);
&::backdrop {
background-color: var(--shadeColor, rgba(0, 0, 0, 0.2));
}
>.content {
display: flex;
flex-direction: column;
max-height: 80dvh;
overflow: hidden;
padding: 30px;
>.close {
font-family: var(--emojiFont, sans-serif);
font-size: 12px;
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
user-select: none;
}
}
}

View File

@ -0,0 +1,40 @@
import type { ComponentChildren } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import styles from './modal.module.css';
interface IProps {
open: boolean;
class?: string;
className?: string;
onClose?: () => void;
children?: ComponentChildren;
}
export const Modal = ({ children, open, onClose, ['class']: cls, className }: IProps) => {
const ref = useRef<HTMLDialogElement>(null);
const handleClickWrapper = useCallback((e: MouseEvent) => {
if (e.currentTarget instanceof HTMLDialogElement) {
onClose?.();
} else {
e.stopPropagation();
}
}, [onClose]);
useEffect(() => {
if (open) {
ref.current?.showModal();
} else {
ref.current?.close();
}
}, [open]);
return (
<dialog ref={ref} onMouseDown={handleClickWrapper} class={styles.dialog}>
<div class={`${styles.content} ${cls ?? className ?? ''}`} onMouseDown={handleClickWrapper}>
<div class={styles.close} onClick={onClose}></div>
{children}
</div>
</dialog>
);
};

View File

@ -0,0 +1,11 @@
import { useCallback, useState } from "preact/hooks";
export const useBool = (initialValue: boolean) => {
const [value, setValue] = useState(initialValue);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
const toggle = useCallback(() => setValue(v => !v), []);
return { value, setTrue, setFalse, toggle };
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -10,11 +10,17 @@
--shadeColor: rgba(0, 128, 128, 0.3);
--border: 1px solid var(--color);
--border-radius: 4px;
--emojiFont: "Noto Emoji", sans-serif;
--emojiColorFont: "Noto Color Emoji", sans-serif;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color) transparent;
}
textarea,
input {
color: var(--color);
@ -34,8 +40,19 @@ textarea {
border: none;
resize: none;
width: 100%;
scrollbar-width: thin;
scrollbar-color: var(--color) transparent;
min-height: 100px;
padding: 4px;
&.border {
border: var(--border);
}
}
button {
border: var(--border);
background-color: var(--backgroundColor);
color: var(--color);
cursor: pointer;
}
body {
@ -97,16 +114,21 @@ body {
&.role-user {
background-color: var(--shadeColor);
:not(.last-user) .content .text {
&: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: row;
flex-direction: column;
width: 100%;
gap: 8px;
@ -116,14 +138,15 @@ body {
min-height: 100px;
height: unset;
resize: vertical;
line-height: 1.25;
line-height: 1.5;
padding: 5px;
border-radius: 3px;
border-radius: var(--border-radius);
}
>.text {
flex-grow: 1;
width: 100%;
animation-duration: 300ms;
>.bold {
font-weight: bold;
@ -141,7 +164,9 @@ body {
>.buttons {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 8px;
>.icon {
@ -158,6 +183,19 @@ body {
font-family: var(--emojiColorFont);
}
}
>.swipes {
display: flex;
width: 100%;
flex-direction: row;
justify-content: space-between;
gap: 8px;
>div {
cursor: pointer;
font-size: 20px;
}
}
}
}
}
@ -173,3 +211,27 @@ body {
}
}
}
@keyframes swipe-from-left {
0% {
position: relative;
left: -100%;
}
100% {
position: relative;
left: 0;
}
}
@keyframes swipe-from-right {
0% {
position: relative;
right: -100%;
}
100% {
position: relative;
right: 0;
}
}

View File

@ -1,29 +1,31 @@
import { useContext, useEffect, useRef } from "preact/hooks";
import { GlobalContext } from "../context";
import { StateContext } from "../contexts/state";
import { Message } from "./message";
import { MessageTools } from "../messages";
import { DOMTools } from "../dom";
export const Chat = () => {
const { messages } = useContext(GlobalContext);
const { messages } = useContext(StateContext);
const chatRef = useRef<HTMLDivElement>(null);
const lastMessage = messages.at(-1);
const lastMessageSwipe = lastMessage?.swipes[lastMessage.currentSwipe];
const lastMessageContent = lastMessageSwipe?.displayContent ?? lastMessageSwipe.content;
const lastMessageSwipe = MessageTools.getSwipe(lastMessage);
const lastMessageContent = lastMessageSwipe?.content;
const lastUserId = messages.findLastIndex(m => m.role === 'user');
const lastAssistantId = messages.findLastIndex(m => m.role === 'assistant');
useEffect(() => {
if (chatRef.current) {
chatRef.current.scrollTo({
top: chatRef.current.scrollHeight,
behavior: 'smooth',
});
}
DOMTools.scrollDown(chatRef.current);
}, [messages.length, lastMessageContent]);
return (
<div class="chat" ref={chatRef}>
{messages.map((m, i) => (
<Message message={m} key={i} index={i} isLastUser={i === lastUserId}/>
<Message
message={m}
key={i} index={i}
isLastUser={i === lastUserId} isLastAssistant={i === lastAssistantId}
/>
))}
</div>
);

View File

@ -1,9 +1,10 @@
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { GlobalContext } from "../context";
import { LLM } from "../llm";
import { StateContext } from "../contexts/state";
import { LLMContext } from "../contexts/llm";
export const Header = () => {
const { connectionUrl, setConnectionUrl } = useContext(GlobalContext);
const llm = useContext(LLMContext);
const { connectionUrl, setConnectionUrl } = useContext(StateContext);
const [urlValid, setUrlValid] = useState(false);
const [urlEditing, setUrlEditing] = useState(false);
@ -26,7 +27,7 @@ export const Header = () => {
useEffect(() => {
if (!urlEditing) {
LLM.getContextLength().then(length => {
llm.getContextLength().then(length => {
setUrlValid(length > 0);
});
}

View File

@ -1,16 +1,21 @@
import { useCallback, useContext } from "preact/hooks";
import { GlobalContext } from "../context";
import { DOMTools } from "../dom";
import { StateContext } from "../contexts/state";
import { LLMContext } from "../contexts/llm";
export const Input = () => {
const { input, setInput, addMessage, continueMessage } = useContext(GlobalContext);
const { input, setInput, addMessage, continueMessage } = useContext(StateContext);
const { generating } = useContext(LLMContext);
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();
if (newInput) {
addMessage(newInput, 'user', true);
@ -18,7 +23,8 @@ export const Input = () => {
} else {
continueMessage();
}
}, [input]);
}
}, [input, setInput, generating]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -30,7 +36,7 @@ export const Input = () => {
return (
<div class="chat-input">
<textarea onInput={handleChange} onKeyDown={handleKeyDown} value={input} />
<button onClick={handleSend}>{input ? 'Send' : 'Continue'}</button>
<button onClick={handleSend} disabled={generating}>{input ? 'Send' : 'Continue'}</button>
</div>
);
}

View File

@ -1,27 +1,32 @@
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { formatMessage, type IMessage } from "../messages";
import { GlobalContext } from "../context";
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";
interface IProps {
message: IMessage;
index: number;
isLastUser: boolean;
isLastAssistant: boolean;
}
export const Message = ({ message, index, isLastUser }: IProps) => {
const { editMessage, deleteMessage } = useContext(GlobalContext);
export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps) => {
const { messages, editMessage, deleteMessage, setCurrentSwipe, addSwipe } = useContext(StateContext);
const [editing, setEditing] = useState(false);
const [savedMessage, setSavedMessage] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const textRef = useRef<HTMLDivElement>(null);
const swipe = useMemo(() => message.swipes[message.currentSwipe], [message.swipes, message.currentSwipe]);
const content = useMemo(() => swipe.displayContent ?? swipe.content, [swipe]);
const htmlContent = useMemo(() => formatMessage(content), [content]);
const assistantModalOpen = useBool(false);
const content = useMemo(() => MessageTools.getSwipe(message)?.content, [message]);
const htmlContent = useMemo(() => MessageTools.format(content ?? ''), [content]);
const handleToggleEdit = useCallback(() => {
setEditing(!editing);
if (!editing) {
setSavedMessage(content);
setSavedMessage(content ?? '');
}
}, [editing, content]);
@ -42,36 +47,65 @@ export const Message = ({ message, index, isLastUser }: IProps) => {
const newContent = e.target.value;
editMessage(index, newContent);
}
DOMTools.fixHeight(e.target);
}, [editMessage, index]);
useEffect(() => {
if (textareaRef.current) {
const area = textareaRef.current;
area.style.height = '0'; // reset
area.style.height = `${area.scrollHeight + 10}px`;
}
}, [content, editing]);
const handleSwipeLeft = useCallback(() => {
setCurrentSwipe(index, message.currentSwipe - 1);
DOMTools.animate(textRef.current, 'swipe-from-left');
}, [setCurrentSwipe, index, message]);
const handleSwipeRight = useCallback(() => {
setCurrentSwipe(index, message.currentSwipe + 1);
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">
{editing
? <textarea onInput={handleEdit} value={content} class="edit-input" ref={textareaRef} />
: <div class="text" dangerouslySetInnerHTML={{ __html: htmlContent }} />
? <textarea onInput={handleEdit} value={content} class="edit-input" />
: <div class="text" dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} />
}
{(isLastUser || message.role === 'assistant') &&
<div class="buttons">
{editing
? <>
<button class="icon" onClick={handleToggleEdit}></button>
<button class="icon" onClick={handleCancelEdit}></button>
<button class="icon" onClick={handleDeleteMessage}>🗑</button>
<button class="icon" onClick={handleCancelEdit}></button>
</>
: <>
<button class="icon" onClick={handleToggleEdit}>🖊</button>
{isLastAssistant &&
<div class="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>
}
</>
}
</div>
}
</div>
<MiniChat
history={messages}
open={assistantModalOpen.value}
onClose={assistantModalOpen.setFalse}
buttons={{ 'Add swipe': handleAssistantAddSwipe }}
/>
</div>
);
};

View File

@ -0,0 +1,25 @@
.minichat {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
.user {
background-color: var(--shadeColor);
}
textarea {
overflow: hidden;
min-height: 60px;
}
}
.buttons {
margin-top: 8px;
height: 24px;
display: flex;
flex-direction: row;
gap: 8px;
}

View File

@ -0,0 +1,111 @@
import { MessageTools, type IMessage } from "../../messages"
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Modal } from "@common/components/modal/modal";
import { DOMTools } from "../../dom";
import styles from './minichat.module.css';
import { LLMContext } from "../../contexts/llm";
interface IProps {
open: boolean;
onClose: () => void;
history?: IMessage[];
buttons?: Record<string, (answer: string) => void>;
}
export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps) => {
const { generating, generate } = useContext(LLMContext);
const [messages, setMessages] = useState<IMessage[]>([]);
const ref = useRef<HTMLDivElement>(null);
const answer = useMemo(() =>
MessageTools.getSwipe(messages.filter(m => m.role === 'assistant').at(-1))?.content,
[messages]
);
const fitTextareas = useCallback((height = false) => {
ref.current?.querySelectorAll('textarea').forEach(height ? DOMTools.fixHeight : DOMTools.scrollDown);
}, []);
useEffect(() => {
fitTextareas();
}, [messages]);
useEffect(() => {
DOMTools.scrollDown(ref.current);
}, [messages.length]);
useEffect(() => {
setMessages([MessageTools.create('', 'user', true)]);
}, [open])
const handleGenerate = useCallback(async () => {
if (messages.length > 0 && !generating) {
const promptMessages: IMessage[] = [...history, ...messages];
const { prompt } = await MessageTools.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 })) {
text += chunk;
setMessages(MessageTools.updateContent(newMessages, messageId, text));
}
setMessages([
...MessageTools.updateContent(newMessages, messageId, MessageTools.trimSentence(text)),
MessageTools.create('', 'user', true),
]);
fitTextareas(true);
MessageTools.playReady();
}
}, [messages, history, generating]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
}, [handleGenerate]);
const handleChange = useCallback((i: number, e: InputEvent) => {
if (e.target instanceof HTMLTextAreaElement) {
setMessages(MessageTools.updateContent(messages, i, e.target.value));
}
DOMTools.fixHeight(e.target);
}, [messages]);
if (!open) {
return null;
}
return (
<Modal open onClose={onClose}>
<div class={styles.minichat} ref={ref}>
<div>
{messages.map((m, i) => (
<textarea
key={i}
class={`border ${styles[m.role]}`}
value={MessageTools.getSwipe(m)?.content ?? ''}
onInput={(e) => handleChange(i, e)}
onKeyDown={handleKeyDown}
/>
))}
</div>
</div>
<div class={styles.buttons}>
<button onClick={handleGenerate} disabled={generating}>
Generate
</button>
{answer && Object.entries(buttons).map(([label, onClick], i) => (
<button key={i} onClick={() => onClick(answer)}>
{label}
</button>
))}
</div>
</Modal>
)
}

View File

@ -14,7 +14,7 @@ export const WORLD_INFO = p`
### 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.
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.
@ -24,6 +24,9 @@ export const WORLD_INFO = p`
- 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
@ -54,24 +57,32 @@ export const WORLD_INFO = p`
**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 instead of magic. 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 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) => p`
This is a description of how you should ${isStart ? 'start' : 'continue'} this story: ${prompt}
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.
`;
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 %}`;

View File

@ -1,124 +0,0 @@
import { createContext } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";
import { compilePrompt, type IMessage } from "./messages";
import { LLM } from "./llm";
import { loadContext, saveContext, type IContext } from "./globalConfig";
import messageSound from './assets/message.mp3';
export interface IActions {
setConnectionUrl: (url: string) => void;
setInput: (url: string) => void;
setName: (name: string) => void;
setMessages: (messages: IMessage[]) => void;
addMessage: (content: string, role: IMessage['role'], triggerNext?: boolean) => void;
editMessage: (index: number, content: string) => void;
deleteMessage: (index: number) => void;
continueMessage: () => void;
}
export type IGlobalContext = IContext & IActions;
export const GlobalContext = createContext<IGlobalContext>({} as IGlobalContext);
export const GlobalContextProvider = ({ 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 [messages, setMessages] = useState(loadedContext.messages);
const [triggerNext, setTriggerNext] = useState(false);
const actions: IActions = useMemo(() => ({
setConnectionUrl,
setInput,
setName,
setMessages: (newMessages) => setMessages(newMessages.slice()),
addMessage: (content, role, triggerNext = false) => {
setMessages(messages => [
...messages,
{ role, currentSwipe: 0, swipes: [{ content }] }
]);
setTriggerNext(triggerNext);
},
editMessage: (index, content) => setMessages(messages => (
messages.map(
(m, i) => ({
...m,
swipes: i === index
? m.swipes.map((s, si) => (si === m.currentSwipe ? { content } : s))
: m.swipes
})
)
)),
deleteMessage: (index) => setMessages(messages =>
messages.filter((_, i) => i !== index)
),
continueMessage: () => setTriggerNext(true),
}), []);
useEffect(() => void (async () => {
if (triggerNext) {
setTriggerNext(false);
const lastMessage = messages.at(-1);
const isContinue = lastMessage?.role === 'assistant';
let promptMessages = isContinue ? messages.slice(0, -1) : messages;
let prompt = await compilePrompt(promptMessages);
let messageId: number;
let text: string;
let generatedLength = 0;
if (isContinue) {
messageId = messages.length - 1;
text = lastMessage?.swipes[lastMessage.currentSwipe].content;
prompt += text;
} else {
messageId = messages.length;
text = '';
actions.addMessage('', 'assistant');
}
for (let attempt = 0; attempt < 2; attempt++) {
for await (const chunk of LLM.generate(prompt)) {
text += chunk;
generatedLength += chunk.trim().length;
actions.editMessage(messageId, text);
}
if (generatedLength > 100) {
break;
} else {
text = text.trim() + '\n\n';
promptMessages.push({ role: 'assistant', currentSwipe: 0, swipes: [{ content: text.trim() }] });
promptMessages.push({ role: 'user', currentSwipe: 0, swipes: [{ content: '(continue)' }] });
prompt = await compilePrompt(promptMessages, {rawUser: true});
}
}
messageSound.currentTime = 0;
messageSound.play();
}
})(), [triggerNext, messages]);
const rawContext: IContext = {
connectionUrl,
input,
name,
messages,
};
const context = useMemo(() => rawContext, Object.values(rawContext));
useEffect(() => {
saveContext(context);
}, [context]);
const value = useMemo(() => ({ ...context, ...actions }), [context, actions])
return (
<GlobalContext.Provider value={value}>
{children}
</GlobalContext.Provider>
);
}

View File

@ -0,0 +1,169 @@
import Lock from "@common/lock";
import SSE from "@common/sse";
import { GENERATION_SETTINGS } from "../const";
import { createContext } from "preact";
import { useContext, useEffect, useMemo } from "preact/hooks";
import { MessageTools } from "../messages";
import { StateContext } from "./state";
import { useBool } from "@common/hooks/useBool";
interface IContext {
generating: boolean;
}
interface IActions {
generate: (prompt: string, extraSettings?: Partial<typeof GENERATION_SETTINGS>) => 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 generating = useBool(false);
const actions: IActions = useMemo(() => ({
generate: async function* (prompt, extraSettings = {}) {
if (!connectionUrl) {
return;
}
try {
generating.setTrue();
console.log('[LLM.generate]', prompt);
const sse = new SSE(`${connectionUrl}/api/extra/generate/stream`, {
payload: JSON.stringify({
...GENERATION_SETTINGS,
...extraSettings,
prompt,
}),
});
const messages: string[] = [];
const messageLock = new Lock();
let end = false;
sse.addEventListener('message', (e) => {
if (e.data) {
{
const { token, finish_reason } = JSON.parse(e.data);
messages.push(token);
if (finish_reason && finish_reason !== 'null') {
end = true;
}
}
}
messageLock.release();
});
const handleEnd = () => {
end = true;
messageLock.release();
};
sse.addEventListener('error', handleEnd);
sse.addEventListener('abort', handleEnd);
sse.addEventListener('readystatechange', (e) => {
if (e.readyState === SSE.CLOSED) handleEnd();
});
while (!end || messages.length) {
while (messages.length > 0) {
const message = messages.shift();
if (message != null) {
try {
yield message;
} catch { }
}
}
if (!end) {
await messageLock.wait();
}
}
sse.close();
} finally {
generating.setFalse();
}
},
countTokens: async (prompt) => {
if (!connectionUrl) {
return 0;
}
try {
const response = await fetch(`${connectionUrl}/api/extra/tokencount`, {
body: JSON.stringify({ prompt }),
headers: { 'Content-Type': 'applicarion/json' },
method: 'POST',
});
if (response.ok) {
const { value } = await response.json();
return value;
}
} catch (e) {
console.log('Error counting tokens', e);
}
return 0;
},
getContextLength: async() => {
if (!connectionUrl) {
return 0;
}
try {
const response = await fetch(`${connectionUrl}/api/extra/true_max_context_length`);
if (response.ok) {
const { value } = await response.json();
return value;
}
} catch (e) {
console.log('Error getting max tokens', e);
}
return 0;
},
}), [connectionUrl]);
useEffect(() => void (async () => {
if (triggerNext && !generating) {
setTriggerNext(false);
let messageId = messages.length - 1;
let text: string = '';
const { prompt, isRegen } = await MessageTools.compilePrompt(messages);
if (!isRegen) {
addMessage('', 'assistant');
messageId++;
}
for await (const chunk of actions.generate(prompt)) {
text += chunk;
editMessage(messageId, text);
}
text = MessageTools.trimSentence(text);
editMessage(messageId, text);
MessageTools.playReady();
}
})(), [triggerNext, messages, generating]);
const rawContext: IContext = {
generating: generating.value,
};
const context = useMemo(() => rawContext, Object.values(rawContext));
const value = useMemo(() => ({ ...context, ...actions }), [context, actions])
return (
<LLMContext.Provider value={value}>
{children}
</LLMContext.Provider>
);
}

View File

@ -0,0 +1,165 @@
import { createContext } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";
import { MessageTools, type IMessage } from "../messages";
interface IContext {
connectionUrl: string;
input: string;
name: string;
messages: IMessage[];
triggerNext: boolean;
}
interface IActions {
setConnectionUrl: (url: string) => void;
setInput: (url: string) => void;
setName: (name: string) => void;
setTriggerNext: (triggerNext: boolean) => void;
setMessages: (messages: IMessage[]) => void;
addMessage: (content: string, role: IMessage['role'], triggerNext?: boolean) => void;
editMessage: (index: number, content: string, triggerNext?: boolean) => void;
deleteMessage: (index: number) => void;
setCurrentSwipe: (index: number, swipe: number) => void;
addSwipe: (index: number, content: string) => void;
continueMessage: () => void;
}
const SAVE_KEY = 'ai_game_save_state';
export const saveContext = (context: IContext) => {
localStorage.setItem(SAVE_KEY, JSON.stringify({
...context,
triggerNext: false,
}));
}
export const loadContext = (): IContext => {
const defaultContext: IContext = {
connectionUrl: 'http://192.168.10.102:5001',
input: '',
name: 'Maya',
messages: [],
triggerNext: false,
};
let loadedContext: Partial<IContext> = {};
try {
const json = localStorage.getItem(SAVE_KEY);
if (json) {
loadedContext = JSON.parse(json);
}
} catch { }
return { ...defaultContext, ...loadedContext };
}
export type IStateContext = IContext & IActions;
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 [messages, setMessages] = useState(loadedContext.messages);
const [triggerNext, setTriggerNext] = useState(false);
const actions: IActions = useMemo(() => ({
setConnectionUrl,
setInput,
setName,
setTriggerNext,
setMessages: (newMessages) => setMessages(newMessages.slice()),
addMessage: (content, role, triggerNext = false) => {
setMessages(messages => [
...messages,
MessageTools.create(content, role),
]);
setTriggerNext(triggerNext);
},
editMessage: (index, content, triggerNext = false) => {
setMessages(messages => MessageTools.updateContent(messages, index, content));
setTriggerNext(triggerNext);
},
deleteMessage: (index) => setMessages(messages =>
messages.filter((_, i) => i !== index)
),
setCurrentSwipe: (index, currentSwipe) => {
let shouldTrigger = false;
setMessages(messages =>
messages.map(
(message, i) => {
if (i === index) {
const swipes = message.swipes.slice();
const latestSwipe = swipes.at(-1);
if (currentSwipe >= swipes.length) {
if (latestSwipe.content.length > 0) {
currentSwipe = swipes.length;
swipes.push({ content: '' });
} else {
currentSwipe = swipes.length - 1;
}
shouldTrigger = true;
} else while (currentSwipe < 0) {
currentSwipe += swipes.length;
}
return {
...message, swipes, currentSwipe
};
} else {
return message;
}
}
)
);
setTriggerNext(shouldTrigger);
},
addSwipe: (index, content) => setMessages(messages =>
messages.map(
(message, i) => {
if (i === index) {
const swipes = [...message.swipes, { content }];
return {
...message,
swipes,
currentSwipe: swipes.length - 1,
};
} else {
return message;
}
}
)
),
continueMessage: () => setTriggerNext(true),
}), []);
const rawContext: IContext = {
connectionUrl,
input,
name,
messages,
triggerNext,
};
const context = useMemo(() => rawContext, Object.values(rawContext));
useEffect(() => {
saveContext(context);
}, [context]);
const value = useMemo(() => ({ ...context, ...actions }), [context, actions])
return (
<StateContext.Provider value={value}>
{children}
</StateContext.Provider>
);
}

24
src/games/ai/dom.ts Normal file
View File

@ -0,0 +1,24 @@
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 HTMLElement) {
e.style.animationName = '';
e.style.animationName = animationName;
}
}
export const scrollDown = (e: unknown) => {
if (e instanceof HTMLElement) {
e.scrollTo({
top: e.scrollHeight,
behavior: 'smooth',
});
}
}
}

View File

@ -1,37 +0,0 @@
import type { IMessage } from "./messages";
export interface IContext {
connectionUrl: string;
input: string;
name: string;
messages: IMessage[];
}
export const GLOBAL_CONFIG: IContext = {
connectionUrl: 'http://192.168.10.102:5001',
input: '',
name: 'Maya',
messages: [],
};
const SAVE_KEY = 'ai_game_save_state';
export const saveContext = (context: IContext) => {
Object.assign(GLOBAL_CONFIG, context);
localStorage.setItem(SAVE_KEY, JSON.stringify(context));
}
export const loadContext = (): IContext => {
const defaultContext: IContext = GLOBAL_CONFIG;
let loadedContext: Partial<IContext> = {};
try {
const json = localStorage.getItem(SAVE_KEY);
if (json) {
loadedContext = JSON.parse(json);
}
} catch { }
return Object.assign(GLOBAL_CONFIG, { ...defaultContext, ...loadedContext });
}

View File

@ -1,15 +1,18 @@
import { render } from "preact";
import { GlobalContextProvider } from "./context";
import { StateContextProvider } from "./contexts/state";
import { App } from "./components/app";
import './assets/style.css';
import './assets/emoji.css';
import { LLMContextProvider } from "./contexts/llm";
export default function main() {
render(
<GlobalContextProvider>
<StateContextProvider>
<LLMContextProvider>
<App />
</GlobalContextProvider>,
</LLMContextProvider>
</StateContextProvider>,
document.body
);
}

View File

@ -1,110 +0,0 @@
import Lock from "@common/lock";
import SSE from "@common/sse";
import { GLOBAL_CONFIG } from "./globalConfig";
import { GENERATION_SETTINGS } from "./const";
export namespace LLM {
export async function* generate(prompt: string) {
const host = GLOBAL_CONFIG.connectionUrl;
if (!host) {
return;
}
console.log('[LLM.generate]', prompt);
const sse = new SSE(`${host}/api/extra/generate/stream`, {
payload: JSON.stringify({
...GENERATION_SETTINGS,
prompt,
}),
});
const messages: string[] = [];
const messageLock = new Lock();
let end = false;
sse.addEventListener('message', (e) => {
if (e.data) {
{
const { token, finish_reason } = JSON.parse(e.data);
messages.push(token);
if (finish_reason && finish_reason !== 'null') {
end = true;
}
}
}
messageLock.release();
});
const handleEnd = () => {
end = true;
messageLock.release();
};
sse.addEventListener('error', handleEnd);
sse.addEventListener('abort', handleEnd);
sse.addEventListener('readystatechange', (e) => {
if (e.readyState === SSE.CLOSED) handleEnd();
});
while (!end || messages.length) {
while (messages.length > 0) {
const message = messages.shift();
if (message != null) {
try {
yield message;
} catch { }
}
}
if (!end) {
await messageLock.wait();
}
}
sse.close();
}
export async function countTokens(prompt: string): Promise<number> {
const host = GLOBAL_CONFIG.connectionUrl;
if (!host) {
return 0;
}
try {
const response = await fetch(`${host}/api/extra/tokencount`, {
body: JSON.stringify({ prompt }),
headers: { 'Content-Type': 'applicarion/json' },
method: 'POST',
});
if (response.ok) {
const { value } = await response.json();
return value;
}
} catch (e) {
console.log('Error counting tokens', e);
}
return 0;
}
export async function getContextLength(): Promise<number> {
const host = GLOBAL_CONFIG.connectionUrl;
if (!host) {
return 0;
}
try {
const response = await fetch(`${host}/api/extra/true_max_context_length`);
if (response.ok) {
const { value } = await response.json();
return value;
}
} catch (e) {
console.log('Error getting max tokens', e);
}
return 0;
}
}

View File

@ -1,15 +1,16 @@
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 {
content: string;
displayContent?: string;
}
export interface IMessage {
role: 'user' | 'assistant' | 'system';
currentSwipe: number;
swipes: ISwipe[];
technical?: boolean;
}
interface ITemplateMessage {
@ -17,7 +18,18 @@ interface ITemplateMessage {
content: string;
}
export const applyChatTemplate = (messages: ITemplateMessage[], templateString: string, eosToken = '</s>') => {
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 => (
{ role, currentSwipe: 0, swipes: [{ content }], technical }
);
export const playReady = () => {
messageSound.currentTime = 0;
messageSound.play();
}
export const applyChatTemplate = (messages: ITemplateMessage[], templateString: string, eosToken = '</s>') => {
const template = new Template(templateString);
const prompt = template.render({
@ -28,26 +40,70 @@ export const applyChatTemplate = (messages: ITemplateMessage[], templateString:
});
return prompt;
}
interface ICompileArgs {
rawUser?: boolean;
}
export const compilePrompt = async (messages: IMessage[], { rawUser }: ICompileArgs = {}): Promise<string> => {
const system = `${SYSTEM_PROMPT}\n\n${WORLD_INFO}`.trim();
const story = messages.filter(m => m.role === 'assistant').map(m => m.swipes[m.currentSwipe].content.trim()).join('\n\n');
const userMessages = messages.filter(m => m.role === 'user');
const lastUserMessage = userMessages.at(-1);
let userPrompt: string | undefined = lastUserMessage?.swipes[lastUserMessage.currentSwipe].content;
if (!rawUser && userPrompt) {
userPrompt = CONTINUE_PROPMT(userPrompt, story.length > 0);
}
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 });
@ -56,12 +112,17 @@ export const compilePrompt = async (messages: IMessage[], { rawUser }: ICompileA
if (userPrompt) {
templateMessages.push({ role: 'user', content: userPrompt });
}
}
const prompt = applyChatTemplate(templateMessages, LLAMA_TEMPLATE);
return prompt;
}
return {
prompt,
isContinue,
isRegen,
};
}
export const formatMessage = (message: string): string => {
export const format = (message: string): string => {
const replaceRegex = /([*"]\*?)/ig;
const splitToken = '___SPLIT_AWOORWA___';
@ -109,4 +170,40 @@ export const formatMessage = (message: string): string => {
}
return resultHTML;
}
export const trimSentence = (text: string): string => {
let latestEnd = -1;
let latestPairEnd = text.length;
for (const end of '.!?;…*"`)}]\n') {
latestEnd = Math.max(latestEnd, text.lastIndexOf(end));
}
for (const char of '*"`') {
const idx = text.lastIndexOf(char);
const match = text.match(new RegExp(`[${char}]`, 'g'));
if (match && match.length % 2 !== 0) {
latestPairEnd = Math.min(latestPairEnd, idx - 1);
}
}
latestEnd = Math.min(latestEnd, latestPairEnd);
if (latestEnd > 0) {
text = text.slice(0, latestEnd + 1);
}
return text.trimEnd();
}
export const updateContent = (messages: IMessage[], index: number, content: string) => (
messages.map(
(m, i) => ({
...m,
swipes: i === index
? m.swipes.map((s, si) => (si === m.currentSwipe ? { content } : s))
: m.swipes
})
)
)
}