1
0
Fork 0

AIStory: mesage sound, connection, deletion, prompt format

This commit is contained in:
Pabloader 2024-11-01 12:28:56 +00:00
parent 5bee177d84
commit 2a9f9d1979
11 changed files with 428 additions and 106 deletions

Binary file not shown.

View File

@ -4,21 +4,35 @@
--color: #DCDCD2;
--italicColor: #AFAFAF;
--quoteColor: #D4E5FF;
--green: #AFAFAF;
--red: #7F0000;
--green: #007F00;
--shadeColor: rgba(0, 128, 128, 0.3);
--border: 1px solid var(--color);
--emojiFont: "Noto Emoji", sans-serif;
--emojiColorFont: "Noto Color Emoji", sans-serif;
}
textarea {
textarea,
input {
color: var(--color);
background-color: var(--backgroundColor);
font-size: 1em;
font-family: sans-serif;
background-color: transparent;
resize: none;
appearance: none;
outline: none;
}
input {
border: var(--border);
}
textarea {
border: none;
resize: none;
width: 100%;
scrollbar-width: thin;
scrollbar-color: var(--color) transparent;
@ -48,8 +62,18 @@ body {
display: flex;
flex-direction: row;
height: 36px;
background-color: yellow;
width: 100%;
border: var(--border);
>input {
&.valid {
background-color: var(--green);
}
&.invalid {
background-color: var(--red);
}
}
}
>.chat {
@ -62,26 +86,68 @@ body {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--color) transparent;
border: 1px solid var(--color);
border: var(--border);
border-bottom: none;
border-top: none;
>.message {
width: 100%;
padding: 12px;
>.header {
&.role-user {
background-color: var(--shadeColor);
:not(.last-user) .content .text {
opacity: 0.5;
}
}
>.content {
white-space: pre-wrap;
line-height: 1.5;
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 6px;
width: 100%;
gap: 8px;
>.name {
>textarea {
background-color: var(--backgroundColorDark);
border: var(--border);
min-height: 100px;
height: unset;
resize: vertical;
line-height: 1.25;
padding: 5px;
border-radius: 3px;
}
>.text {
flex-grow: 1;
width: 100%;
>.bold {
font-weight: bold;
}
>.italic {
font-style: italic;
color: var(--italicColor);
}
>.quote {
color: var(--quoteColor);
}
}
>.buttons {
display: flex;
flex-direction: column;
gap: 8px;
>.icon {
font-family: var(--emojiFont);
font-weight: bold;
font-size: 20px;
border: none;
background: transparent;
padding: 0;
@ -94,22 +160,6 @@ body {
}
}
}
>.content {
white-space: pre-wrap;
line-height: 1.5;
>textarea {
background-color: var(--backgroundColorDark);
border: 1px solid var(--italicColor);
min-height: 100px;
height: unset;
resize: vertical;
line-height: 1.25;
padding: 5px;
border-radius: 3px;
}
}
}
}
@ -119,7 +169,7 @@ body {
height: auto;
min-height: 48px;
width: 100%;
border: 1px solid var(--color);
border: var(--border);
}
}
}

View File

@ -9,6 +9,7 @@ export const Chat = () => {
const lastMessage = messages.at(-1);
const lastMessageSwipe = lastMessage?.swipes[lastMessage.currentSwipe];
const lastMessageContent = lastMessageSwipe?.displayContent ?? lastMessageSwipe.content;
const lastUserId = messages.findLastIndex(m => m.role === 'user');
useEffect(() => {
if (chatRef.current) {
@ -22,7 +23,7 @@ export const Chat = () => {
return (
<div class="chat" ref={chatRef}>
{messages.map((m, i) => (
<Message message={m} key={i} index={i} />
<Message message={m} key={i} index={i} isLastUser={i === lastUserId}/>
))}
</div>
);

View File

@ -1,7 +1,44 @@
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { GlobalContext } from "../context";
import { LLM } from "../llm";
export const Header = () => {
const { connectionUrl, setConnectionUrl } = useContext(GlobalContext);
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">
Header
<input value={connectionUrl}
onInput={handleEditUrl}
onFocus={handleFocusUrl}
onBlur={handleBlurUrl}
class={urlEditing ? '' : urlValid ? 'valid' : 'invalid'} />
</div>
);
}

View File

@ -2,7 +2,7 @@ import { useCallback, useContext } from "preact/hooks";
import { GlobalContext } from "../context";
export const Input = () => {
const { input, setInput, addMessage } = useContext(GlobalContext);
const { input, setInput, addMessage, continueMessage } = useContext(GlobalContext);
const handleChange = useCallback((e: Event) => {
if (e.target instanceof HTMLTextAreaElement) {
@ -15,6 +15,8 @@ export const Input = () => {
if (newInput) {
addMessage(newInput, 'user', true);
setInput('');
} else {
continueMessage();
}
}, [input]);
@ -28,7 +30,7 @@ export const Input = () => {
return (
<div class="chat-input">
<textarea onInput={handleChange} onKeyDown={handleKeyDown} value={input} />
<button onClick={handleSend}>Send</button>
<button onClick={handleSend}>{input ? 'Send' : 'Continue'}</button>
</div>
);
}

View File

@ -1,20 +1,22 @@
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { IMessage } from "../messages";
import { formatMessage, type IMessage } from "../messages";
import { GlobalContext } from "../context";
interface IProps {
message: IMessage;
index: number;
isLastUser: boolean;
}
export const Message = ({ message, index }: IProps) => {
const { name, editMessage } = useContext(GlobalContext);
export const Message = ({ message, index, isLastUser }: IProps) => {
const { editMessage, deleteMessage } = useContext(GlobalContext);
const [editing, setEditing] = useState(false);
const [savedMessage, setSavedMessage] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(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 handleToggleEdit = useCallback(() => {
setEditing(!editing);
@ -28,6 +30,13 @@ export const Message = ({ message, index }: IProps) => {
editMessage(index, savedMessage);
}, [editMessage, index, savedMessage]);
const handleDeleteMessage = useCallback(() => {
if (confirm('Delete message?')) {
setEditing(false);
deleteMessage(index);
}
}, [deleteMessage, index]);
const handleEdit = useCallback((e: InputEvent) => {
if (e.target instanceof HTMLTextAreaElement) {
const newContent = e.target.value;
@ -44,16 +53,18 @@ export const Message = ({ message, index }: IProps) => {
}, [content, editing]);
return (
<div class="message">
<div class="header">
<div class="name">
{message.role === 'user' ? name : '---'}
</div>
<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 }} />
}
<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={handleToggleEdit}>🖊</button>
@ -61,12 +72,6 @@ export const Message = ({ message, index }: IProps) => {
}
</div>
</div>
<div class="content">
{editing
? <textarea onInput={handleEdit} value={content} class="edit-input" ref={textareaRef} />
: content
}
</div>
</div>
);
};

View File

@ -1,10 +1,13 @@
export const SYSTEM_PROMPT = `
You are creative and skilled writer.
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.
`.trim().replace(/[ \r\n]+/ig, ' ');
`.replace(/[ \r\n]+/ig, ' ');
export const WORLD_INFO = `
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.
@ -58,6 +61,133 @@ Maya looks like a neka with a pair of wolf ears, black hair and a tail with whit
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.
`.trim();
`;
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}
Remember that this story should be infinite and go forever. 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>', '<|']
}

View File

@ -4,6 +4,8 @@ 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;
@ -13,6 +15,8 @@ export interface IActions {
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;
@ -53,20 +57,47 @@ export const GlobalContextProvider = ({ children }: { children?: any }) => {
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';
const prompt = await compilePrompt(messages);
const messageId = messages.length;
let text = '';
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]);

View File

@ -33,5 +33,5 @@ export const loadContext = (): IContext => {
}
} catch { }
return { ...defaultContext, ...loadedContext };
return Object.assign(GLOBAL_CONFIG, { ...defaultContext, ...loadedContext });
}

View File

@ -1,6 +1,7 @@
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) {
@ -10,11 +11,12 @@ export namespace LLM {
return;
}
console.log('[LLM.generate]', prompt);
const sse = new SSE(`${host}/api/extra/generate/stream`, {
payload: JSON.stringify({
temperature: 1.0,
...GENERATION_SETTINGS,
prompt,
stop_sequence: [],
}),
});
@ -71,7 +73,7 @@ export namespace LLM {
return 0;
}
try {
const response = await fetch(`${host}/extra/tokencount`, {
const response = await fetch(`${host}/api/extra/tokencount`, {
body: JSON.stringify({ prompt }),
headers: { 'Content-Type': 'applicarion/json' },
method: 'POST',
@ -94,7 +96,7 @@ export namespace LLM {
return 0;
}
try {
const response = await fetch(`${host}/extra/true_max_context_length`);
const response = await fetch(`${host}/api/extra/true_max_context_length`);
if (response.ok) {
const { value } = await response.json();
return value;

View File

@ -1,5 +1,5 @@
import { Template } from "@huggingface/jinja";
import { LLAMA_TEMPLATE, SYSTEM_PROMPT, WORLD_INFO } from "./const";
import { LLAMA_TEMPLATE, p, SYSTEM_PROMPT, CONTINUE_PROPMT, WORLD_INFO, START_PROMPT } from "./const";
export interface ISwipe {
content: string;
@ -30,19 +30,83 @@ export const applyChatTemplate = (messages: ITemplateMessage[], templateString:
return prompt;
}
export const compilePrompt = async (messages: IMessage[]): Promise<string> => {
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 latestMessage = messages.filter(m => m.role === 'user').at(-1);
const userPrompt = `This is a description of how you should continue this story: ${latestMessage.swipes[latestMessage.currentSwipe].content}`;
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);
}
const templateMessages: ITemplateMessage[] = [
{ role: 'system', content: system },
{ role: 'assistant', content: story },
{ role: 'user', content: userPrompt },
];
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);
console.log(prompt);
return prompt;
}
export const formatMessage = (message: string): string => {
const replaceRegex = /([*"]\*?)/ig;
const splitToken = '___SPLIT_AWOORWA___';
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
const parts = preparedMessage.split(splitToken);
let isText = true;
let keepPart = true;
let resultHTML = '';
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;
}