|
<html> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
|
<meta name="color-scheme" content="light dark"> |
|
<title>🦙 llama.cpp - chat</title> |
|
|
|
|
|
<link href="./deps_daisyui.min.css" rel="stylesheet" type="text/css" /> |
|
<script src="./deps_tailwindcss.js"></script> |
|
<style type="text/tailwindcss"> |
|
.markdown { |
|
h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; } |
|
pre { |
|
@apply whitespace-pre-wrap rounded-lg p-2; |
|
border: 1px solid currentColor; |
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
.bg-base-100 {background-color: var(--fallback-b1,oklch(var(--b1)/1))} |
|
.bg-base-200 {background-color: var(--fallback-b2,oklch(var(--b2)/1))} |
|
.bg-base-300 {background-color: var(--fallback-b3,oklch(var(--b3)/1))} |
|
.text-base-content {color: var(--fallback-bc,oklch(var(--bc)/1))} |
|
.show-on-hover { |
|
@apply opacity-0 group-hover:opacity-100; |
|
} |
|
.btn-mini { |
|
@apply cursor-pointer hover:shadow-md; |
|
} |
|
.chat-screen { max-width: 900px; } |
|
|
|
.chat-bubble-base-300 { |
|
--tw-bg-opacity: 1; |
|
--tw-text-opacity: 1; |
|
@apply bg-base-300 text-base-content; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
<div id="app" class="flex flex-row opacity-0"> |
|
|
|
<div class="flex flex-col bg-black bg-opacity-5 w-64 py-8 px-4 h-screen overflow-y-auto"> |
|
<h2 class="font-bold mb-4 ml-4">Conversations</h2> |
|
|
|
|
|
<div :class="{ |
|
'btn btn-ghost justify-start': true, |
|
'btn-active': messages.length === 0, |
|
}" @click="newConversation"> |
|
+ New conversation |
|
</div> |
|
<div v-for="conv in conversations" :class="{ |
|
'btn btn-ghost justify-start font-normal': true, |
|
'btn-active': conv.id === viewingConvId, |
|
}" @click="setViewingConv(conv.id)"> |
|
<span class="truncate">{{ conv.messages[0].content }}</span> |
|
</div> |
|
<div class="text-center text-xs opacity-40 mt-auto mx-4"> |
|
Conversations are saved to browser's localStorage |
|
</div> |
|
</div> |
|
|
|
<div class="chat-screen flex flex-col w-screen h-screen px-8 mx-auto"> |
|
|
|
<div class="flex flex-row items-center"> |
|
<div class="grow text-2xl font-bold mt-8 mb-6"> |
|
🦙 llama.cpp - chat |
|
</div> |
|
|
|
|
|
<div class="flex items-center"> |
|
<button v-if="messages.length > 0" class="btn mr-1" @click="deleteConv(viewingConvId)" :disabled="isGenerating"> |
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16"> |
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/> |
|
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/> |
|
</svg> |
|
</button> |
|
<button v-if="messages.length > 0" class="btn mr-1" @click="downloadConv(viewingConvId)" :disabled="isGenerating"> |
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16"> |
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/> |
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/> |
|
</svg> |
|
</button> |
|
<button class="btn" @click="showConfigDialog = true" :disabled="isGenerating"> |
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16"> |
|
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492M5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0"/> |
|
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115z"/> |
|
</svg> |
|
</button> |
|
|
|
|
|
<div class="dropdown dropdown-end dropdown-bottom"> |
|
<div tabindex="0" role="button" class="btn m-1"> |
|
Theme |
|
<svg width="12px" height="12px" class="inline-block h-2 w-2 fill-current opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"> |
|
<path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path> |
|
</svg> |
|
</div> |
|
<ul tabindex="0" class="dropdown-content bg-base-300 rounded-box z-[1] w-52 p-2 shadow-2xl h-80 overflow-y-auto"> |
|
<li> |
|
<button |
|
class="btn btn-sm btn-block w-full btn-ghost justify-start" |
|
:class="{ 'btn-active': selectedTheme === 'auto' }" |
|
@click="setSelectedTheme('auto')"> |
|
auto |
|
</button> |
|
</li> |
|
<li v-for="theme in themes"> |
|
<input |
|
type="radio" |
|
name="theme-dropdown" |
|
class="theme-controller btn btn-sm btn-block w-full btn-ghost justify-start" |
|
:aria-label="theme" |
|
:value="theme" |
|
:checked="selectedTheme === theme" |
|
@click="setSelectedTheme(theme)" /> |
|
</li> |
|
</ul> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<div id="messages-list" class="flex flex-col grow overflow-y-auto"> |
|
<div class="mt-auto flex justify-center"> |
|
|
|
{{ messages.length === 0 ? 'Send a message to start' : '' }} |
|
</div> |
|
<div v-for="msg in messages" class="group"> |
|
<div :class="{ |
|
'chat': true, |
|
'chat-start': msg.role !== 'user', |
|
'chat-end': msg.role === 'user', |
|
}"> |
|
<div :class="{ |
|
'chat-bubble markdown': true, |
|
'chat-bubble-base-300': msg.role !== 'user', |
|
}"> |
|
|
|
<template v-if="editingMsg && editingMsg.id === msg.id"> |
|
<textarea |
|
class="textarea textarea-bordered bg-base-100 text-base-content w-96" |
|
v-model="msg.content"></textarea> |
|
<br/> |
|
<button class="btn btn-ghost mt-2 mr-2" @click="editingMsg = null">Cancel</button> |
|
<button class="btn mt-2" @click="editUserMsgAndRegenerate(msg)">Submit</button> |
|
</template> |
|
|
|
<vue-markdown v-else :source="msg.content" /> |
|
</div> |
|
</div> |
|
|
|
|
|
<div :class="{'text-right': msg.role === 'user'}" class="mx-4 mt-2 mb-2"> |
|
|
|
<button v-if="msg.role === 'user'" class="badge btn-minishow-on-hover " @click="editingMsg = msg" :disabled="isGenerating"> |
|
✍️ Edit |
|
</button> |
|
|
|
<button v-if="msg.role === 'assistant'" class="badge btn-mini show-on-hover mr-2" @click="regenerateMsg(msg)" :disabled="isGenerating"> |
|
🔄 Regenerate |
|
</button> |
|
<button v-if="msg.role === 'assistant'" class="badge btn-mini show-on-hover mr-2" @click="copyMsg(msg)" :disabled="isGenerating"> |
|
📋 Copy |
|
</button> |
|
</div> |
|
</div> |
|
|
|
|
|
<div id="pending-msg" class="chat chat-start"> |
|
<div v-if="pendingMsg" class="chat-bubble markdown chat-bubble-base-300"> |
|
<span v-if="!pendingMsg.content" class="loading loading-dots loading-md"></span> |
|
<vue-markdown v-else :source="pendingMsg.content" /> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="flex flex-row items-center mt-8 mb-6"> |
|
<textarea |
|
class="textarea textarea-bordered w-full" |
|
placeholder="Type a message (Shift+Enter to add a new line)" |
|
v-model="inputMsg" |
|
@keydown.enter.exact.prevent="sendMessage" |
|
@keydown.enter.shift.exact.prevent="inputMsg += '\n'" |
|
:disabled="isGenerating" |
|
id="msg-input" |
|
></textarea> |
|
<button v-if="!isGenerating" class="btn btn-primary ml-2" @click="sendMessage" :disabled="inputMsg.length === 0">Send</button> |
|
<button v-else class="btn btn-neutral ml-2" @click="stopGeneration">Stop</button> |
|
</div> |
|
</div> |
|
|
|
|
|
<dialog class="modal" :class="{'modal-open': showConfigDialog}"> |
|
<div class="modal-box"> |
|
<h3 class="text-lg font-bold mb-6">Settings</h3> |
|
<div class="h-[calc(90vh-12rem)] overflow-y-auto"> |
|
<p class="opacity-40 mb-6">Settings below are saved in browser's localStorage</p> |
|
<settings-modal-short-input :config-key="'apiKey'" :config-default="configDefault" :config-info="configInfo" v-model="config.apiKey"></settings-modal-short-input> |
|
<label class="form-control mb-2"> |
|
<div class="label">System Message</div> |
|
<textarea class="textarea textarea-bordered h-24" :placeholder="'Default: ' + configDefault.systemMessage" v-model="config.systemMessage"></textarea> |
|
</label> |
|
<template v-for="configKey in ['temperature', 'top_k', 'top_p', 'min_p', 'max_tokens']"> |
|
<settings-modal-short-input :config-key="configKey" :config-default="configDefault" :config-info="configInfo" v-model="config[configKey]" /> |
|
</template> |
|
|
|
|
|
<details class="collapse collapse-arrow bg-base-200 mb-2 overflow-visible"> |
|
<summary class="collapse-title font-bold">Other sampler settings</summary> |
|
<div class="collapse-content"> |
|
|
|
<settings-modal-short-input label="Samplers queue" :config-key="'samplers'" :config-default="configDefault" :config-info="configInfo" v-model="config.samplers"></settings-modal-short-input> |
|
|
|
<template v-for="configKey in ['dynatemp_range', 'dynatemp_exponent', 'typical_p', 'xtc_probability', 'xtc_threshold']"> |
|
<settings-modal-short-input :config-key="configKey" :config-default="configDefault" :config-info="configInfo" v-model="config[configKey]" /> |
|
</template> |
|
</div> |
|
</details> |
|
|
|
<details class="collapse collapse-arrow bg-base-200 mb-2 overflow-visible"> |
|
<summary class="collapse-title font-bold">Penalties settings</summary> |
|
<div class="collapse-content"> |
|
<template v-for="configKey in ['repeat_last_n', 'repeat_penalty', 'presence_penalty', 'frequency_penalty', 'dry_multiplier', 'dry_base', 'dry_allowed_length', 'dry_penalty_last_n']"> |
|
<settings-modal-short-input :config-key="configKey" :config-default="configDefault" :config-info="configInfo" v-model="config[configKey]" /> |
|
</template> |
|
</div> |
|
</details> |
|
|
|
<details class="collapse collapse-arrow bg-base-200 mb-2 overflow-visible"> |
|
<summary class="collapse-title font-bold">Advanced config</summary> |
|
<div class="collapse-content"> |
|
<label class="form-control mb-2"> |
|
|
|
<div class="label inline">Custom JSON config (For more info, refer to <a class="underline" href="https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md" target="_blank" rel="noopener noreferrer">server documentation</a>)</div> |
|
<textarea class="textarea textarea-bordered h-24" placeholder="Example: { "mirostat": 1, "min_p": 0.1 }" v-model="config.custom"></textarea> |
|
</label> |
|
</div> |
|
</details> |
|
</div> |
|
|
|
|
|
<div class="modal-action"> |
|
<button class="btn" @click="resetConfigDialog">Reset to default</button> |
|
<button class="btn" @click="closeAndDiscardConfigDialog">Close</button> |
|
<button class="btn btn-primary" @click="closeAndSaveConfigDialog">Save and close</button> |
|
</div> |
|
</div> |
|
</dialog> |
|
</div> |
|
|
|
|
|
<template id="settings-modal-short-input"> |
|
<label class="input input-bordered join-item grow flex items-center gap-2 mb-2"> |
|
|
|
<div class="dropdown dropdown-hover"> |
|
<div tabindex="0" role="button" class="font-bold">{{ label || configKey }}</div> |
|
<div class="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4"> |
|
{{ configInfo[configKey] || '(no help message available)' }} |
|
</div> |
|
</div> |
|
|
|
<input type="text" class="grow" :placeholder="'Default: ' + (configDefault[configKey] || 'none')" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /> |
|
</label> |
|
</template> |
|
|
|
<script src="./deps_markdown-it.js"></script> |
|
<script type="module"> |
|
import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js'; |
|
import { llama } from './completion.js'; |
|
|
|
|
|
const isString = (x) => !!x.toLowerCase; |
|
const isNumeric = (n) => !isString(n) && !isNaN(n); |
|
const escapeAttr = (str) => str.replace(/>/g, '>').replace(/"/g, '"'); |
|
const copyStr = (str) => navigator.clipboard.writeText(str); |
|
|
|
|
|
const BASE_URL = localStorage.getItem('base') |
|
|| (new URL('.', document.baseURI).href).toString(); |
|
const CONFIG_DEFAULT = { |
|
|
|
apiKey: '', |
|
systemMessage: 'You are a helpful assistant.', |
|
|
|
samplers: 'dkypmxt', |
|
temperature: 0.8, |
|
dynatemp_range: 0.0, |
|
dynatemp_exponent: 1.0, |
|
top_k: 40, |
|
top_p: 0.95, |
|
min_p: 0.05, |
|
xtc_probability: 0.0, |
|
xtc_threshold: 0.1, |
|
typical_p: 1.0, |
|
repeat_last_n: 64, |
|
repeat_penalty: 1.0, |
|
presence_penalty: 0.0, |
|
frequency_penalty: 0.0, |
|
dry_multiplier: 0.0, |
|
dry_base: 1.75, |
|
dry_allowed_length: 2, |
|
dry_penalty_last_n: -1, |
|
max_tokens: -1, |
|
custom: '', |
|
}; |
|
const CONFIG_INFO = { |
|
apiKey: 'Set the API Key if you are using --api-key option for the server.', |
|
systemMessage: 'The starting message that defines how model should behave.', |
|
samplers: 'The order at which samplers are applied, in simplified way. Default is "dkypmxt": dry->top_k->typ_p->top_p->min_p->xtc->temperature', |
|
temperature: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.', |
|
dynatemp_range: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.', |
|
dynatemp_exponent: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.', |
|
top_k: 'Keeps only k top tokens.', |
|
top_p: 'Limits tokens to those that together have a cumulative probability of at least p', |
|
min_p: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.', |
|
xtc_probability: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.', |
|
xtc_threshold: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.', |
|
typical_p: 'Sorts and limits tokens based on the difference between log-probability and entropy.', |
|
repeat_last_n: 'Last n tokens to consider for penalizing repetition', |
|
repeat_penalty: 'Controls the repetition of token sequences in the generated text', |
|
presence_penalty: 'Limits tokens based on whether they appear in the output or not.', |
|
frequency_penalty: 'Limits tokens based on how often they appear in the output.', |
|
dry_multiplier: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.', |
|
dry_base: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.', |
|
dry_allowed_length: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.', |
|
dry_penalty_last_n: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.', |
|
max_tokens: 'The maximum number of token per output.', |
|
custom: '', |
|
}; |
|
|
|
const CONFIG_NUMERIC_KEYS = Object.entries(CONFIG_DEFAULT).filter(e => isNumeric(e[1])).map(e => e[0]); |
|
|
|
const THEMES = ['light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', 'winter', 'dim', 'nord', 'sunset']; |
|
|
|
|
|
const VueMarkdown = defineComponent( |
|
(props) => { |
|
const md = shallowRef(new markdownit({ breaks: true })); |
|
const origFenchRenderer = md.value.renderer.rules.fence; |
|
md.value.renderer.rules.fence = (tokens, idx, ...args) => { |
|
const content = tokens[idx].content; |
|
const origRendered = origFenchRenderer(tokens, idx, ...args); |
|
return `<div class="relative my-4"> |
|
<div class="text-right sticky top-4 mb-2 mr-2 h-0"> |
|
<button class="badge btn-mini" onclick="copyStr(${escapeAttr(JSON.stringify(content))})">📋 Copy</button> |
|
</div> |
|
${origRendered} |
|
</div>`; |
|
}; |
|
window.copyStr = copyStr; |
|
const content = computed(() => md.value.render(props.source)); |
|
return () => h("div", { innerHTML: content.value }); |
|
}, |
|
{ props: ["source"] } |
|
); |
|
|
|
|
|
const SettingsModalShortInput = defineComponent({ |
|
template: document.getElementById('settings-modal-short-input').innerHTML, |
|
props: { |
|
label: { type: String, required: false }, |
|
configKey: String, |
|
configDefault: Object, |
|
configInfo: Object, |
|
modelValue: [Object, String, Number], |
|
}, |
|
}); |
|
|
|
|
|
|
|
|
|
const StorageUtils = { |
|
|
|
getAllConversations() { |
|
const res = []; |
|
for (const key in localStorage) { |
|
if (key.startsWith('conv-')) { |
|
res.push(JSON.parse(localStorage.getItem(key))); |
|
} |
|
} |
|
res.sort((a, b) => b.lastModified - a.lastModified); |
|
return res; |
|
}, |
|
|
|
getOneConversation(convId) { |
|
return JSON.parse(localStorage.getItem(convId) || 'null'); |
|
}, |
|
|
|
appendMsg(convId, msg) { |
|
if (msg.content === null) return; |
|
const conv = StorageUtils.getOneConversation(convId) || { |
|
id: convId, |
|
lastModified: Date.now(), |
|
messages: [], |
|
}; |
|
conv.messages.push(msg); |
|
conv.lastModified = Date.now(); |
|
localStorage.setItem(convId, JSON.stringify(conv)); |
|
}, |
|
getNewConvId() { |
|
return `conv-${Date.now()}`; |
|
}, |
|
remove(convId) { |
|
localStorage.removeItem(convId); |
|
}, |
|
filterAndKeepMsgs(convId, predicate) { |
|
const conv = StorageUtils.getOneConversation(convId); |
|
if (!conv) return; |
|
conv.messages = conv.messages.filter(predicate); |
|
conv.lastModified = Date.now(); |
|
localStorage.setItem(convId, JSON.stringify(conv)); |
|
}, |
|
popMsg(convId) { |
|
const conv = StorageUtils.getOneConversation(convId); |
|
if (!conv) return; |
|
const msg = conv.messages.pop(); |
|
conv.lastModified = Date.now(); |
|
if (conv.messages.length === 0) { |
|
StorageUtils.remove(convId); |
|
} else { |
|
localStorage.setItem(convId, JSON.stringify(conv)); |
|
} |
|
return msg; |
|
}, |
|
|
|
|
|
getConfig() { |
|
const savedVal = JSON.parse(localStorage.getItem('config') || '{}'); |
|
|
|
return { |
|
...CONFIG_DEFAULT, |
|
...savedVal, |
|
}; |
|
}, |
|
setConfig(config) { |
|
localStorage.setItem('config', JSON.stringify(config)); |
|
}, |
|
getTheme() { |
|
return localStorage.getItem('theme') || 'auto'; |
|
}, |
|
setTheme(theme) { |
|
if (theme === 'auto') { |
|
localStorage.removeItem('theme'); |
|
} else { |
|
localStorage.setItem('theme', theme); |
|
} |
|
}, |
|
}; |
|
|
|
|
|
|
|
const chatScrollToBottom = (requiresNearBottom) => { |
|
const msgListElem = document.getElementById('messages-list'); |
|
const spaceToBottom = msgListElem.scrollHeight - msgListElem.scrollTop - msgListElem.clientHeight; |
|
if (!requiresNearBottom || (spaceToBottom < 100)) { |
|
setTimeout(() => msgListElem.scrollTo({ top: msgListElem.scrollHeight }), 1); |
|
} |
|
}; |
|
|
|
const mainApp = createApp({ |
|
components: { |
|
VueMarkdown, |
|
SettingsModalShortInput, |
|
}, |
|
data() { |
|
return { |
|
conversations: StorageUtils.getAllConversations(), |
|
messages: [], |
|
viewingConvId: StorageUtils.getNewConvId(), |
|
inputMsg: '', |
|
isGenerating: false, |
|
pendingMsg: null, |
|
stopGeneration: () => {}, |
|
selectedTheme: StorageUtils.getTheme(), |
|
config: StorageUtils.getConfig(), |
|
showConfigDialog: false, |
|
editingMsg: null, |
|
|
|
themes: THEMES, |
|
configDefault: {...CONFIG_DEFAULT}, |
|
configInfo: {...CONFIG_INFO}, |
|
} |
|
}, |
|
computed: {}, |
|
mounted() { |
|
document.getElementById('app').classList.remove('opacity-0'); |
|
|
|
const pendingMsgElem = document.getElementById('pending-msg'); |
|
const resizeObserver = new ResizeObserver(() => { |
|
if (this.isGenerating) chatScrollToBottom(true); |
|
}); |
|
resizeObserver.observe(pendingMsgElem); |
|
}, |
|
methods: { |
|
setSelectedTheme(theme) { |
|
this.selectedTheme = theme; |
|
StorageUtils.setTheme(theme); |
|
}, |
|
newConversation() { |
|
if (this.isGenerating) return; |
|
this.viewingConvId = StorageUtils.getNewConvId(); |
|
this.editingMsg = null; |
|
this.fetchMessages(); |
|
chatScrollToBottom(); |
|
}, |
|
setViewingConv(convId) { |
|
if (this.isGenerating) return; |
|
this.viewingConvId = convId; |
|
this.editingMsg = null; |
|
this.fetchMessages(); |
|
chatScrollToBottom(); |
|
}, |
|
deleteConv(convId) { |
|
if (this.isGenerating) return; |
|
if (window.confirm('Are you sure to delete this conversation?')) { |
|
StorageUtils.remove(convId); |
|
if (this.viewingConvId === convId) { |
|
this.viewingConvId = StorageUtils.getNewConvId(); |
|
this.editingMsg = null; |
|
} |
|
this.fetchConversation(); |
|
this.fetchMessages(); |
|
} |
|
}, |
|
downloadConv(convId) { |
|
const conversation = StorageUtils.getOneConversation(convId); |
|
if (!conversation) { |
|
alert('Conversation not found.'); |
|
return; |
|
} |
|
const conversationJson = JSON.stringify(conversation, null, 2); |
|
const blob = new Blob([conversationJson], { type: 'application/json' }); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = url; |
|
a.download = `conversation_${convId}.json`; |
|
document.body.appendChild(a); |
|
a.click(); |
|
document.body.removeChild(a); |
|
URL.revokeObjectURL(url); |
|
}, |
|
async sendMessage() { |
|
if (!this.inputMsg) return; |
|
const currConvId = this.viewingConvId; |
|
|
|
StorageUtils.appendMsg(currConvId, { |
|
id: Date.now(), |
|
role: 'user', |
|
content: this.inputMsg, |
|
}); |
|
this.fetchConversation(); |
|
this.fetchMessages(); |
|
this.inputMsg = ''; |
|
this.editingMsg = null; |
|
this.generateMessage(currConvId); |
|
chatScrollToBottom(); |
|
}, |
|
async generateMessage(currConvId) { |
|
if (this.isGenerating) return; |
|
this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null }; |
|
this.isGenerating = true; |
|
this.editingMsg = null; |
|
|
|
try { |
|
const abortController = new AbortController(); |
|
this.stopGeneration = () => abortController.abort(); |
|
const params = { |
|
messages: [ |
|
{ role: 'system', content: this.config.systemMessage }, |
|
...this.messages, |
|
], |
|
stream: true, |
|
cache_prompt: true, |
|
samplers: this.config.samplers, |
|
temperature: this.config.temperature, |
|
dynatemp_range: this.config.dynatemp_range, |
|
dynatemp_exponent: this.config.dynatemp_exponent, |
|
top_k: this.config.top_k, |
|
top_p: this.config.top_p, |
|
min_p: this.config.min_p, |
|
typical_p: this.config.typical_p, |
|
xtc_probability: this.config.xtc_probability, |
|
xtc_threshold: this.config.xtc_threshold, |
|
repeat_last_n: this.config.repeat_last_n, |
|
repeat_penalty: this.config.repeat_penalty, |
|
presence_penalty: this.config.presence_penalty, |
|
frequency_penalty: this.config.frequency_penalty, |
|
dry_multiplier: this.config.dry_multiplier, |
|
dry_base: this.config.dry_base, |
|
dry_allowed_length: this.config.dry_allowed_length, |
|
dry_penalty_last_n: this.config.dry_penalty_last_n, |
|
max_tokens: this.config.max_tokens, |
|
...(this.config.custom.length ? JSON.parse(this.config.custom) : {}), |
|
...(this.config.apiKey ? { api_key: this.config.apiKey } : {}), |
|
}; |
|
const config = { |
|
controller: abortController, |
|
api_url: BASE_URL, |
|
endpoint: '/chat/completions', |
|
}; |
|
for await (const chunk of llama(prompt, params, config)) { |
|
const stop = chunk.data.stop; |
|
const addedContent = chunk.data.choices[0].delta.content; |
|
const lastContent = this.pendingMsg.content || ''; |
|
if (addedContent) { |
|
this.pendingMsg = { |
|
id: this.pendingMsg.id, |
|
role: 'assistant', |
|
content: lastContent + addedContent, |
|
}; |
|
} |
|
} |
|
|
|
StorageUtils.appendMsg(currConvId, this.pendingMsg); |
|
this.fetchConversation(); |
|
this.fetchMessages(); |
|
setTimeout(() => document.getElementById('msg-input').focus(), 1); |
|
} catch (error) { |
|
if (error.name === 'AbortError') { |
|
|
|
StorageUtils.appendMsg(currConvId, this.pendingMsg); |
|
this.fetchConversation(); |
|
this.fetchMessages(); |
|
} else { |
|
console.error(error); |
|
alert(error); |
|
|
|
const lastUserMsg = StorageUtils.popMsg(currConvId); |
|
this.inputMsg = lastUserMsg ? lastUserMsg.content : ''; |
|
} |
|
} |
|
|
|
this.pendingMsg = null; |
|
this.isGenerating = false; |
|
this.stopGeneration = () => {}; |
|
this.fetchMessages(); |
|
chatScrollToBottom(); |
|
}, |
|
|
|
|
|
regenerateMsg(msg) { |
|
if (this.isGenerating) return; |
|
|
|
const currConvId = this.viewingConvId; |
|
StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id); |
|
this.fetchConversation(); |
|
this.fetchMessages(); |
|
this.generateMessage(currConvId); |
|
}, |
|
copyMsg(msg) { |
|
copyStr(msg.content); |
|
}, |
|
editUserMsgAndRegenerate(msg) { |
|
if (this.isGenerating) return; |
|
const currConvId = this.viewingConvId; |
|
const newContent = msg.content; |
|
this.editingMsg = null; |
|
StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id); |
|
StorageUtils.appendMsg(currConvId, { |
|
id: Date.now(), |
|
role: 'user', |
|
content: newContent, |
|
}); |
|
this.fetchConversation(); |
|
this.fetchMessages(); |
|
this.generateMessage(currConvId); |
|
}, |
|
|
|
|
|
closeAndSaveConfigDialog() { |
|
try { |
|
if (this.config.custom.length) JSON.parse(this.config.custom); |
|
} catch (error) { |
|
alert('Invalid JSON for custom config. Please either fix it or leave it empty.'); |
|
return; |
|
} |
|
for (const key of CONFIG_NUMERIC_KEYS) { |
|
if (isNaN(this.config[key]) || this.config[key].toString().trim().length === 0) { |
|
alert(`Invalid number for ${key} (expected an integer or a float)`); |
|
return; |
|
} |
|
this.config[key] = parseFloat(this.config[key]); |
|
} |
|
this.showConfigDialog = false; |
|
StorageUtils.setConfig(this.config); |
|
}, |
|
closeAndDiscardConfigDialog() { |
|
this.showConfigDialog = false; |
|
this.config = StorageUtils.getConfig(); |
|
}, |
|
resetConfigDialog() { |
|
if (window.confirm('Are you sure to reset all settings?')) { |
|
this.config = {...CONFIG_DEFAULT}; |
|
} |
|
}, |
|
|
|
|
|
fetchConversation() { |
|
this.conversations = StorageUtils.getAllConversations(); |
|
}, |
|
fetchMessages() { |
|
this.messages = StorageUtils.getOneConversation(this.viewingConvId)?.messages ?? []; |
|
}, |
|
}, |
|
}); |
|
mainApp.config.errorHandler = alert; |
|
try { |
|
mainApp.mount('#app'); |
|
} catch (err) { |
|
console.error(err); |
|
document.getElementById('app').innerHTML = `<div style="margin:2em auto"> |
|
Failed to start app. Please try clearing localStorage and try again.<br/> |
|
<br/> |
|
<button class="btn" onClick="localStorage.clear(); window.location.reload();">Clear localStorage</button> |
|
</div>`; |
|
} |
|
</script> |
|
</body> |
|
|
|
</html> |
|
|