1
0
Fork 0

AIStory: minichat

This commit is contained in:
Pabloader 2024-11-01 21:11:48 +00:00
parent fa77bb2339
commit d917c6e6a9
19 changed files with 745 additions and 356 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 };
};

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,9 +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 {
@ -124,7 +140,7 @@ body {
resize: vertical;
line-height: 1.5;
padding: 5px;
border-radius: 3px;
border-radius: var(--border-radius);
}
>.text {
@ -207,6 +223,7 @@ body {
left: 0;
}
}
@keyframes swipe-from-right {
0% {
position: relative;

View File

@ -1,30 +1,27 @@
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
message={m}
key={i} index={i}
isLastUser={i === lastUserId} isLastAssistant={i === lastAssistantId}

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,24 +1,30 @@
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 () => {
const newInput = input.trim();
if (newInput) {
addMessage(newInput, 'user', true);
setInput('');
} else {
continueMessage();
if (!generating) {
const newInput = input.trim();
if (newInput) {
addMessage(newInput, 'user', true);
setInput('');
} 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,6 +1,9 @@
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;
@ -10,20 +13,20 @@ interface IProps {
}
export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps) => {
const { editMessage, deleteMessage, setCurrentSwipe } = useContext(GlobalContext);
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]);
@ -44,38 +47,31 @@ 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(() => {
setCurrentSwipe(index, message.currentSwipe - 1);
if (textRef.current) {
textRef.current.style.animationName = '';
textRef.current.style.animationName = 'swipe-from-left';
}
DOMTools.animate(textRef.current, 'swipe-from-left');
}, [setCurrentSwipe, index, message]);
const handleSwipeRight = useCallback(() => {
setCurrentSwipe(index, message.currentSwipe + 1);
if (textRef.current) {
textRef.current.style.animationName = '';
textRef.current.style.animationName = 'swipe-from-right';
}
DOMTools.animate(textRef.current, 'swipe-from-right');
}, [setCurrentSwipe, index, message]);
useEffect(() => {
if (textareaRef.current) {
const area = textareaRef.current;
area.style.height = '0'; // reset
area.style.height = `${area.scrollHeight + 10}px`;
}
}, [content, editing]);
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 }} ref={textRef}/>
? <textarea onInput={handleEdit} value={content} class="edit-input" />
: <div class="text" dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} />
}
{(isLastUser || message.role === 'assistant') &&
<div class="buttons">
@ -93,12 +89,23 @@ export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps)
<div onClick={handleSwipeRight}></div>
</div>
}
<button class="icon" onClick={handleToggleEdit}>🖊</button>
<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

@ -24,7 +24,7 @@ export const WORLD_INFO = p`
- Eth is short name for ether kinetics.
- Etherkin is the person who uses ether kinetics.
- No supernatural activity.
- Pretty weak, not capable of manipulating the reality.
- 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.
@ -68,6 +68,7 @@ export const WORLD_INFO = p`
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`
@ -77,9 +78,9 @@ export const START_PROMPT = p`
export const CONTINUE_PROPMT = (prompt?: string, isStart = false) =>
prompt?.trim()
? p`
This is a description of how you should ${isStart ? 'start' : 'continue'} this story: ${prompt.trim()}
${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.`;

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

@ -1,31 +1,66 @@
import { createContext } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";
import { compilePrompt, trimSentence, type IMessage } from "./messages";
import { LLM } from "./llm";
import { loadContext, saveContext, type IContext } from "./globalConfig";
import { MessageTools, type IMessage } from "../messages";
import messageSound from './assets/message.mp3';
import { CONTINUE_PROPMT } from "./const";
interface IContext {
connectionUrl: string;
input: string;
name: string;
messages: IMessage[];
triggerNext: boolean;
}
export interface IActions {
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;
}
export type IGlobalContext = IContext & IActions;
const SAVE_KEY = 'ai_game_save_state';
export const GlobalContext = createContext<IGlobalContext>({} as IGlobalContext);
export const saveContext = (context: IContext) => {
localStorage.setItem(SAVE_KEY, JSON.stringify({
...context,
triggerNext: false,
}));
}
export const GlobalContextProvider = ({ children }: { children?: any }) => {
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);
@ -37,26 +72,18 @@ export const GlobalContextProvider = ({ children }: { children?: any }) => {
setConnectionUrl,
setInput,
setName,
setTriggerNext,
setMessages: (newMessages) => setMessages(newMessages.slice()),
addMessage: (content, role, triggerNext = false) => {
setMessages(messages => [
...messages,
{ role, currentSwipe: 0, swipes: [{ content }] }
MessageTools.create(content, role),
]);
setTriggerNext(triggerNext);
},
editMessage: (index, content, triggerNext = false) => {
setMessages(messages =>
messages.map(
(m, i) => ({
...m,
swipes: i === index
? m.swipes.map((s, si) => (si === m.currentSwipe ? { content } : s))
: m.swipes
})
)
);
setMessages(messages => MessageTools.updateContent(messages, index, content));
setTriggerNext(triggerNext);
},
deleteMessage: (index) => setMessages(messages =>
@ -94,50 +121,32 @@ export const GlobalContextProvider = ({ children }: { children?: any }) => {
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),
}), []);
useEffect(() => void (async () => {
if (triggerNext) {
setTriggerNext(false);
const promptMessages = messages.slice();
const lastMessage = promptMessages.at(-1);
const isAssistantLast = lastMessage?.role === 'assistant';
const isRegen = isAssistantLast && !lastMessage?.swipes[lastMessage.currentSwipe].content;
const isContinue = isAssistantLast && !isRegen;
let messageId = promptMessages.length - 1;
let text: string = '';
if (isContinue) {
promptMessages.push({ role: 'user', currentSwipe: 0, swipes: [{ content: CONTINUE_PROPMT() }] });
}
const prompt = await compilePrompt(promptMessages, { rawUser: isContinue });
if (!isRegen) {
actions.addMessage('', 'assistant');
messageId++;
}
for await (const chunk of LLM.generate(prompt)) {
text += chunk;
actions.editMessage(messageId, text);
}
text = trimSentence(text);
actions.editMessage(messageId, text);
messageSound.currentTime = 0;
messageSound.play();
}
})(), [triggerNext, messages]);
const rawContext: IContext = {
connectionUrl,
input,
name,
messages,
triggerNext,
};
const context = useMemo(() => rawContext, Object.values(rawContext));
@ -149,8 +158,8 @@ export const GlobalContextProvider = ({ children }: { children?: any }) => {
const value = useMemo(() => ({ ...context, ...actions }), [context, actions])
return (
<GlobalContext.Provider value={value}>
<StateContext.Provider value={value}>
{children}
</GlobalContext.Provider>
</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>
<App />
</GlobalContextProvider>,
<StateContextProvider>
<LLMContextProvider>
<App />
</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,120 +18,192 @@ interface ITemplateMessage {
content: string;
}
export const applyChatTemplate = (messages: ITemplateMessage[], templateString: string, eosToken = '</s>') => {
const template = new Template(templateString);
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 }
);
const prompt = template.render({
messages,
bos_token: '',
eos_token: eosToken,
add_generation_prompt: true,
});
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);
export const playReady = () => {
messageSound.currentTime = 0;
messageSound.play();
}
const templateMessages: ITemplateMessage[] = [
{ role: 'system', content: system },
];
export const applyChatTemplate = (messages: ITemplateMessage[], templateString: string, eosToken = '</s>') => {
const template = new Template(templateString);
if (story.length > 0) {
templateMessages.push({ role: 'user', content: START_PROMPT });
templateMessages.push({ role: 'assistant', content: story });
const prompt = template.render({
messages,
bos_token: '',
eos_token: eosToken,
add_generation_prompt: true,
});
return prompt;
}
if (userPrompt) {
templateMessages.push({ role: 'user', content: userPrompt });
interface ICompileArgs {
keepUsers?: number;
}
const prompt = applyChatTemplate(templateMessages, LLAMA_TEMPLATE);
return prompt;
}
interface ICompiledPrompt {
prompt: string;
isContinue: boolean;
isRegen: boolean;
}
export const formatMessage = (message: string): string => {
const replaceRegex = /([*"]\*?)/ig;
const splitToken = '___SPLIT_AWOORWA___';
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;
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
const parts = preparedMessage.split(splitToken);
if (isContinue) {
promptMessages.push(create(CONTINUE_PROPMT()));
}
let isText = true;
let keepPart = true;
const system = `${SYSTEM_PROMPT}\n\n${WORLD_INFO}`.trim();
let resultHTML = '';
const templateMessages: ITemplateMessage[] = [
{ role: 'system', content: system },
];
for (const part of parts) {
if (keepUsers) {
let usersRemaining = messages.filter(m => m.role === 'user').length;
let wasStory = false;
if (isText) {
if (part === '*') {
isText = false;
keepPart = false;
resultHTML += `<span class="italic">`;
} else if (part === '**') {
isText = false;
keepPart = false;
resultHTML += `<span class="bold">`;
} else if (part === '"') {
isText = false;
keepPart = true;
resultHTML += `<span class="quote">"`;
} else {
resultHTML += part;
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 {
if (part === '*' || part === '**') {
resultHTML += `</span>`;
isText = true;
} else if (part === '"') {
resultHTML += `"</span>`;
isText = true;
} else {
resultHTML += part;
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,
};
}
if (!isText) {
resultHTML += `</span>`;
}
export const format = (message: string): string => {
const replaceRegex = /([*"]\*?)/ig;
const splitToken = '___SPLIT_AWOORWA___';
return resultHTML;
}
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
const parts = preparedMessage.split(splitToken);
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));
}
let isText = true;
let keepPart = true;
for (const char of '*"`') {
const idx = text.lastIndexOf(char);
let resultHTML = '';
const match = text.match(new RegExp(`[${char}]`, 'g'));
if (match && match.length % 2 !== 0) {
latestPairEnd = Math.min(latestPairEnd, idx - 1);
for (const part of parts) {
if (isText) {
if (part === '*') {
isText = false;
keepPart = false;
resultHTML += `<span class="italic">`;
} else if (part === '**') {
isText = false;
keepPart = false;
resultHTML += `<span class="bold">`;
} else if (part === '"') {
isText = false;
keepPart = true;
resultHTML += `<span class="quote">"`;
} else {
resultHTML += part;
}
} else {
if (part === '*' || part === '**') {
resultHTML += `</span>`;
isText = true;
} else if (part === '"') {
resultHTML += `"</span>`;
isText = true;
} else {
resultHTML += part;
}
}
}
if (!isText) {
resultHTML += `</span>`;
}
return resultHTML;
}
latestEnd = Math.min(latestEnd, latestPairEnd);
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));
}
if (latestEnd > 0) {
text = text.slice(0, latestEnd + 1);
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();
}
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
})
)
)
}