index.html 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
  1. <html>
  2. <head>
  3. <meta charset="UTF-8">
  4. <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
  5. <meta name="color-scheme" content="light dark">
  6. <title>🦙 llama.cpp - chat</title>
  7. <!-- Note: dependencies can de updated using ./deps.sh script -->
  8. <link href="./deps_daisyui.min.css" rel="stylesheet" type="text/css" />
  9. <script src="./deps_tailwindcss.js"></script>
  10. <style type="text/tailwindcss">
  11. .markdown {
  12. h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
  13. pre {
  14. @apply whitespace-pre-wrap my-4 rounded-lg p-2;
  15. border: 1px solid currentColor;
  16. }
  17. /* TODO: fix markdown table */
  18. }
  19. /*
  20. Note for daisyui: because we're using a subset of daisyui via CDN, many things won't be included
  21. We can manually add the missing styles from https://cdnjs.cloudflare.com/ajax/libs/daisyui/4.12.14/full.css
  22. */
  23. .bg-base-100 {background-color: var(--fallback-b1,oklch(var(--b1)/1))}
  24. .bg-base-200 {background-color: var(--fallback-b2,oklch(var(--b2)/1))}
  25. .bg-base-300 {background-color: var(--fallback-b3,oklch(var(--b3)/1))}
  26. .text-base-content {color: var(--fallback-bc,oklch(var(--bc)/1))}
  27. .btn-mini {
  28. @apply cursor-pointer opacity-0 group-hover:opacity-100 hover:shadow-md;
  29. }
  30. .chat-screen { max-width: 900px; }
  31. /* because the default bubble color is quite dark, we will make a custom one using bg-base-300 */
  32. .chat-bubble-base-300 {
  33. --tw-bg-opacity: 1;
  34. --tw-text-opacity: 1;
  35. @apply bg-base-300 text-base-content;
  36. }
  37. </style>
  38. </head>
  39. <body>
  40. <div id="app" class="flex flex-row opacity-0"> <!-- opacity-0 will be removed on app mounted -->
  41. <!-- sidebar -->
  42. <div class="flex flex-col bg-black bg-opacity-5 w-64 py-8 px-4 h-screen overflow-y-auto">
  43. <h2 class="font-bold mb-4 ml-4">Conversations</h2>
  44. <!-- list of conversations -->
  45. <div :class="{
  46. 'btn btn-ghost justify-start': true,
  47. 'btn-active': messages.length === 0,
  48. }" @click="newConversation">
  49. + New conversation
  50. </div>
  51. <div v-for="conv in conversations" :class="{
  52. 'btn btn-ghost justify-start font-normal': true,
  53. 'btn-active': conv.id === viewingConvId,
  54. }" @click="setViewingConv(conv.id)">
  55. <span class="truncate">{{ conv.messages[0].content }}</span>
  56. </div>
  57. <div class="text-center text-xs opacity-40 mt-auto mx-4">
  58. Conversations are saved to browser's localStorage
  59. </div>
  60. </div>
  61. <div class="chat-screen flex flex-col w-screen h-screen px-8 mx-auto">
  62. <!-- header -->
  63. <div class="flex flex-row items-center">
  64. <div class="grow text-2xl font-bold mt-8 mb-6">
  65. 🦙 llama.cpp - chat
  66. </div>
  67. <!-- action buttons (top right) -->
  68. <div class="flex items-center">
  69. <button v-if="messages.length > 0" class="btn mr-1" @click="deleteConv(viewingConvId)" :disabled="isGenerating">
  70. <!-- delete conversation button -->
  71. <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
  72. <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"/>
  73. <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"/>
  74. </svg>
  75. </button>
  76. <button class="btn" @click="showConfigDialog = true" :disabled="isGenerating">
  77. <!-- edit config button -->
  78. <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
  79. <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"/>
  80. <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"/>
  81. </svg>
  82. </button>
  83. <!-- theme controller is copied from https://daisyui.com/components/theme-controller/ -->
  84. <div class="dropdown dropdown-end dropdown-bottom">
  85. <div tabindex="0" role="button" class="btn m-1">
  86. Theme
  87. <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">
  88. <path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path>
  89. </svg>
  90. </div>
  91. <ul tabindex="0" class="dropdown-content bg-base-300 rounded-box z-[1] w-52 p-2 shadow-2xl h-80 overflow-y-auto">
  92. <li>
  93. <button
  94. class="btn btn-sm btn-block w-full btn-ghost justify-start"
  95. :class="{ 'btn-active': selectedTheme === 'auto' }"
  96. @click="setSelectedTheme('auto')">
  97. auto
  98. </button>
  99. </li>
  100. <li v-for="theme in themes">
  101. <input
  102. type="radio"
  103. name="theme-dropdown"
  104. class="theme-controller btn btn-sm btn-block w-full btn-ghost justify-start"
  105. :aria-label="theme"
  106. :value="theme"
  107. :checked="selectedTheme === theme"
  108. @click="setSelectedTheme(theme)" />
  109. </li>
  110. </ul>
  111. </div>
  112. </div>
  113. </div>
  114. <!-- chat messages -->
  115. <div id="messages-list" class="flex flex-col grow overflow-y-auto">
  116. <div class="mt-auto flex justify-center">
  117. <!-- placeholder to shift the message to the bottom -->
  118. {{ messages.length === 0 ? 'Send a message to start' : '' }}
  119. </div>
  120. <div v-for="msg in messages" class="group">
  121. <div :class="{
  122. 'chat': true,
  123. 'chat-start': msg.role !== 'user',
  124. 'chat-end': msg.role === 'user',
  125. }">
  126. <div :class="{
  127. 'chat-bubble markdown': true,
  128. 'chat-bubble-base-300': msg.role !== 'user',
  129. }">
  130. <!-- textarea for editing message -->
  131. <template v-if="editingMsg && editingMsg.id === msg.id">
  132. <textarea
  133. class="textarea textarea-bordered bg-base-100 text-base-content w-96"
  134. v-model="msg.content"></textarea>
  135. <br/>
  136. <button class="btn btn-ghost mt-2 mr-2" @click="editingMsg = null">Cancel</button>
  137. <button class="btn mt-2" @click="editUserMsgAndRegenerate(msg)">Submit</button>
  138. </template>
  139. <!-- render message as markdown -->
  140. <vue-markdown v-else :source="msg.content" />
  141. </div>
  142. </div>
  143. <!-- actions for each message -->
  144. <div :class="{'text-right': msg.role === 'user'}" class="mx-4 mt-2 mb-2">
  145. <!-- user message -->
  146. <button v-if="msg.role === 'user'" class="badge btn-mini" @click="editingMsg = msg" :disabled="isGenerating">
  147. ✍️ Edit
  148. </button>
  149. <!-- assistant message -->
  150. <button v-if="msg.role === 'assistant'" class="badge btn-mini mr-2" @click="regenerateMsg(msg)" :disabled="isGenerating">
  151. 🔄 Regenerate
  152. </button>
  153. <button v-if="msg.role === 'assistant'" class="badge btn-mini mr-2" @click="copyMsg(msg)" :disabled="isGenerating">
  154. 📋 Copy
  155. </button>
  156. </div>
  157. </div>
  158. <!-- pending (ongoing) assistant message -->
  159. <div id="pending-msg" class="chat chat-start">
  160. <div v-if="pendingMsg" class="chat-bubble markdown chat-bubble-base-300">
  161. <span v-if="!pendingMsg.content" class="loading loading-dots loading-md"></span>
  162. <vue-markdown v-else :source="pendingMsg.content" />
  163. </div>
  164. </div>
  165. </div>
  166. <!-- chat input -->
  167. <div class="flex flex-row items-center mt-8 mb-6">
  168. <textarea
  169. class="textarea textarea-bordered w-full"
  170. placeholder="Type a message (Shift+Enter to add a new line)"
  171. v-model="inputMsg"
  172. @keydown.enter.exact.prevent="sendMessage"
  173. @keydown.enter.shift.exact.prevent="inputMsg += '\n'"
  174. :disabled="isGenerating"
  175. id="msg-input"
  176. ></textarea>
  177. <button v-if="!isGenerating" class="btn btn-primary ml-2" @click="sendMessage" :disabled="inputMsg.length === 0">Send</button>
  178. <button v-else class="btn btn-neutral ml-2" @click="stopGeneration">Stop</button>
  179. </div>
  180. </div>
  181. <!-- modal for editing config -->
  182. <dialog class="modal" :class="{'modal-open': showConfigDialog}">
  183. <div class="modal-box">
  184. <h3 class="text-lg font-bold mb-6">Settings</h3>
  185. <div class="h-[calc(90vh-12rem)] overflow-y-auto">
  186. <p class="opacity-40 mb-6">Settings below are saved in browser's localStorage</p>
  187. <label class="form-control mb-2">
  188. <div class="label">System Message</div>
  189. <textarea class="textarea textarea-bordered h-24" :placeholder="'Default: ' + configDefault.systemMessage" v-model="config.systemMessage"></textarea>
  190. </label>
  191. <template v-for="configKey in ['temperature', 'top_k', 'top_p', 'min_p', 'max_tokens']">
  192. <settings-modal-numeric-input :config-key="configKey" :config-default="configDefault" :config-info="configInfo" v-model="config[configKey]" />
  193. </template>
  194. <!-- TODO: add more sampling-related configs, please regroup them into different "collapse" sections -->
  195. <!-- Section: Other sampler settings -->
  196. <details class="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
  197. <summary class="collapse-title font-bold">Other sampler settings</summary>
  198. <div class="collapse-content">
  199. <template v-for="configKey in ['dynatemp_range', 'dynatemp_exponent', 'typical_p', 'xtc_probability', 'xtc_threshold']">
  200. <settings-modal-numeric-input :config-key="configKey" :config-default="configDefault" :config-info="configInfo" v-model="config[configKey]" />
  201. </template>
  202. </div>
  203. </details>
  204. <!-- Section: Penalties settings -->
  205. <details class="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
  206. <summary class="collapse-title font-bold">Penalties settings</summary>
  207. <div class="collapse-content">
  208. <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']">
  209. <settings-modal-numeric-input :config-key="configKey" :config-default="configDefault" :config-info="configInfo" v-model="config[configKey]" />
  210. </template>
  211. </div>
  212. </details>
  213. <!-- Section: Advanced config -->
  214. <details class="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
  215. <summary class="collapse-title font-bold">Advanced config</summary>
  216. <div class="collapse-content">
  217. <label class="form-control mb-2">
  218. <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>
  219. <textarea class="textarea textarea-bordered h-24" placeholder="Example: { &quot;mirostat&quot;: 1, &quot;min_p&quot;: 0.1 }" v-model="config.custom"></textarea>
  220. </label>
  221. </div>
  222. </details>
  223. </div>
  224. <!-- action buttons -->
  225. <div class="modal-action">
  226. <button class="btn" @click="resetConfigDialog">Reset to default</button>
  227. <button class="btn" @click="closeAndDiscardConfigDialog">Close</button>
  228. <button class="btn btn-primary" @click="closeAndSaveConfigDialog">Save and close</button>
  229. </div>
  230. </div>
  231. </dialog>
  232. </div>
  233. <!-- Template to be used by settings modal -->
  234. <template id="settings-modal-numeric-input">
  235. <label class="input input-bordered join-item grow flex items-center gap-2 mb-2">
  236. <!-- Show help message on hovering on the input label -->
  237. <div class="dropdown dropdown-hover">
  238. <div tabindex="0" role="button" class="font-bold">{{ configKey }}</div>
  239. <div class="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4">
  240. {{ configInfo[configKey] || '(no help message available)' }}
  241. </div>
  242. </div>
  243. <!-- Here we forward v-model from parent to child component, see: https://stackoverflow.com/questions/47311936/v-model-and-child-components -->
  244. <input type="text" class="grow" :placeholder="'Default: ' + (configDefault[configKey] || 'none')" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
  245. </label>
  246. </template>
  247. <script src="./deps_markdown-it.js"></script>
  248. <script type="module">
  249. import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
  250. import { llama } from './completion.js';
  251. const isString = (x) => !!x.toLowerCase;
  252. const isNumeric = (n) => !isString(n) && !isNaN(n);
  253. const BASE_URL = localStorage.getItem('base') // for debugging
  254. || (new URL('.', document.baseURI).href).toString(); // for production
  255. const CONFIG_DEFAULT = {
  256. // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
  257. apiKey: '',
  258. systemMessage: 'You are a helpful assistant.',
  259. // make sure these default values are in sync with `common.h`
  260. temperature: 0.8,
  261. dynatemp_range: 0.0,
  262. dynatemp_exponent: 1.0,
  263. top_k: 40,
  264. top_p: 0.95,
  265. min_p: 0.05,
  266. xtc_probability: 0.0,
  267. xtc_threshold: 0.1,
  268. typical_p: 1.0,
  269. repeat_last_n: 64,
  270. repeat_penalty: 1.0,
  271. presence_penalty: 0.0,
  272. frequency_penalty: 0.0,
  273. dry_multiplier: 0.0,
  274. dry_base: 1.75,
  275. dry_allowed_length: 2,
  276. dry_penalty_last_n: -1,
  277. max_tokens: -1,
  278. custom: '', // custom json-stringified object
  279. };
  280. const CONFIG_INFO = {
  281. apiKey: '',
  282. systemMessage: 'The starting message that defines how model should behave.',
  283. temperature: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
  284. dynatemp_range: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
  285. dynatemp_exponent: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
  286. top_k: 'Keeps only k top tokens.',
  287. top_p: 'Limits tokens to those that together have a cumulative probability of at least p',
  288. min_p: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
  289. xtc_probability: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
  290. xtc_threshold: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
  291. typical_p: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
  292. repeat_last_n: 'Last n tokens to consider for penalizing repetition',
  293. repeat_penalty: 'Controls the repetition of token sequences in the generated text',
  294. presence_penalty: 'Limits tokens based on whether they appear in the output or not.',
  295. frequency_penalty: 'Limits tokens based on how often they appear in the output.',
  296. dry_multiplier: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
  297. dry_base: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
  298. dry_allowed_length: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
  299. 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.',
  300. max_tokens: 'The maximum number of token per output.',
  301. custom: '', // custom json-stringified object
  302. };
  303. // config keys having numeric value (i.e. temperature, top_k, top_p, etc)
  304. const CONFIG_NUMERIC_KEYS = Object.entries(CONFIG_DEFAULT).filter(e => isNumeric(e[1])).map(e => e[0]);
  305. // list of themes supported by daisyui
  306. 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'];
  307. // markdown support
  308. const VueMarkdown = defineComponent(
  309. (props) => {
  310. const md = shallowRef(new markdownit(props.options ?? { breaks: true }));
  311. for (const plugin of props.plugins ?? []) {
  312. md.value.use(plugin);
  313. }
  314. const content = computed(() => md.value.render(props.source));
  315. return () => h("div", { innerHTML: content.value });
  316. },
  317. { props: ["source", "options", "plugins"] }
  318. );
  319. // inout field to be used by settings modal
  320. const SettingsModalNumericInput = defineComponent({
  321. template: document.getElementById('settings-modal-numeric-input').innerHTML,
  322. props: ['configKey', 'configDefault', 'configInfo', 'modelValue'],
  323. });
  324. // coversations is stored in localStorage
  325. // format: { [convId]: { id: string, lastModified: number, messages: [...] } }
  326. // convId is a string prefixed with 'conv-'
  327. const StorageUtils = {
  328. // manage conversations
  329. getAllConversations() {
  330. const res = [];
  331. for (const key in localStorage) {
  332. if (key.startsWith('conv-')) {
  333. res.push(JSON.parse(localStorage.getItem(key)));
  334. }
  335. }
  336. res.sort((a, b) => b.lastModified - a.lastModified);
  337. return res;
  338. },
  339. // can return null if convId does not exist
  340. getOneConversation(convId) {
  341. return JSON.parse(localStorage.getItem(convId) || 'null');
  342. },
  343. // if convId does not exist, create one
  344. appendMsg(convId, msg) {
  345. if (msg.content === null) return;
  346. const conv = StorageUtils.getOneConversation(convId) || {
  347. id: convId,
  348. lastModified: Date.now(),
  349. messages: [],
  350. };
  351. conv.messages.push(msg);
  352. conv.lastModified = Date.now();
  353. localStorage.setItem(convId, JSON.stringify(conv));
  354. },
  355. getNewConvId() {
  356. return `conv-${Date.now()}`;
  357. },
  358. remove(convId) {
  359. localStorage.removeItem(convId);
  360. },
  361. filterAndKeepMsgs(convId, predicate) {
  362. const conv = StorageUtils.getOneConversation(convId);
  363. if (!conv) return;
  364. conv.messages = conv.messages.filter(predicate);
  365. conv.lastModified = Date.now();
  366. localStorage.setItem(convId, JSON.stringify(conv));
  367. },
  368. popMsg(convId) {
  369. const conv = StorageUtils.getOneConversation(convId);
  370. if (!conv) return;
  371. const msg = conv.messages.pop();
  372. conv.lastModified = Date.now();
  373. localStorage.setItem(convId, JSON.stringify(conv));
  374. return msg;
  375. },
  376. // manage config
  377. getConfig() {
  378. const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
  379. // to prevent breaking changes in the future, we always provide default value for missing keys
  380. return {
  381. ...CONFIG_DEFAULT,
  382. ...savedVal,
  383. };
  384. },
  385. setConfig(config) {
  386. localStorage.setItem('config', JSON.stringify(config));
  387. },
  388. getTheme() {
  389. return localStorage.getItem('theme') || 'auto';
  390. },
  391. setTheme(theme) {
  392. if (theme === 'auto') {
  393. localStorage.removeItem('theme');
  394. } else {
  395. localStorage.setItem('theme', theme);
  396. }
  397. },
  398. };
  399. // scroll to bottom of chat messages
  400. // if requiresNearBottom is true, only auto-scroll if user is near bottom
  401. const chatScrollToBottom = (requiresNearBottom) => {
  402. const msgListElem = document.getElementById('messages-list');
  403. const spaceToBottom = msgListElem.scrollHeight - msgListElem.scrollTop - msgListElem.clientHeight;
  404. if (!requiresNearBottom || (spaceToBottom < 100)) {
  405. setTimeout(() => msgListElem.scrollTo({ top: msgListElem.scrollHeight }), 1);
  406. }
  407. };
  408. const mainApp = createApp({
  409. components: {
  410. VueMarkdown,
  411. SettingsModalNumericInput,
  412. },
  413. data() {
  414. return {
  415. conversations: StorageUtils.getAllConversations(),
  416. messages: [], // { id: number, role: 'user' | 'assistant', content: string }
  417. viewingConvId: StorageUtils.getNewConvId(),
  418. inputMsg: '',
  419. isGenerating: false,
  420. pendingMsg: null, // the on-going message from assistant
  421. stopGeneration: () => {},
  422. selectedTheme: StorageUtils.getTheme(),
  423. config: StorageUtils.getConfig(),
  424. showConfigDialog: false,
  425. editingMsg: null,
  426. // const
  427. themes: THEMES,
  428. configDefault: {...CONFIG_DEFAULT},
  429. configInfo: {...CONFIG_INFO},
  430. }
  431. },
  432. computed: {},
  433. mounted() {
  434. document.getElementById('app').classList.remove('opacity-0'); // show app
  435. // scroll to the bottom when the pending message height is updated
  436. const pendingMsgElem = document.getElementById('pending-msg');
  437. const resizeObserver = new ResizeObserver(() => {
  438. if (this.isGenerating) chatScrollToBottom(true);
  439. });
  440. resizeObserver.observe(pendingMsgElem);
  441. },
  442. methods: {
  443. setSelectedTheme(theme) {
  444. this.selectedTheme = theme;
  445. StorageUtils.setTheme(theme);
  446. },
  447. newConversation() {
  448. if (this.isGenerating) return;
  449. this.viewingConvId = StorageUtils.getNewConvId();
  450. this.editingMsg = null;
  451. this.fetchMessages();
  452. chatScrollToBottom();
  453. },
  454. setViewingConv(convId) {
  455. if (this.isGenerating) return;
  456. this.viewingConvId = convId;
  457. this.editingMsg = null;
  458. this.fetchMessages();
  459. chatScrollToBottom();
  460. },
  461. deleteConv(convId) {
  462. if (this.isGenerating) return;
  463. if (window.confirm('Are you sure to delete this conversation?')) {
  464. StorageUtils.remove(convId);
  465. if (this.viewingConvId === convId) {
  466. this.viewingConvId = StorageUtils.getNewConvId();
  467. this.editingMsg = null;
  468. }
  469. this.fetchConversation();
  470. this.fetchMessages();
  471. }
  472. },
  473. async sendMessage() {
  474. if (!this.inputMsg) return;
  475. const currConvId = this.viewingConvId;
  476. StorageUtils.appendMsg(currConvId, {
  477. id: Date.now(),
  478. role: 'user',
  479. content: this.inputMsg,
  480. });
  481. this.fetchConversation();
  482. this.fetchMessages();
  483. this.inputMsg = '';
  484. this.editingMsg = null;
  485. this.generateMessage(currConvId);
  486. chatScrollToBottom();
  487. },
  488. async generateMessage(currConvId) {
  489. if (this.isGenerating) return;
  490. this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null };
  491. this.isGenerating = true;
  492. this.editingMsg = null;
  493. try {
  494. const abortController = new AbortController();
  495. this.stopGeneration = () => abortController.abort();
  496. const params = {
  497. messages: [
  498. { role: 'system', content: this.config.systemMessage },
  499. ...this.messages,
  500. ],
  501. stream: true,
  502. cache_prompt: true,
  503. temperature: this.config.temperature,
  504. dynatemp_range: this.config.dynatemp_range,
  505. dynatemp_exponent: this.config.dynatemp_exponent,
  506. top_k: this.config.top_k,
  507. top_p: this.config.top_p,
  508. min_p: this.config.min_p,
  509. typical_p: this.config.typical_p,
  510. xtc_probability: this.config.xtc_probability,
  511. xtc_threshold: this.config.xtc_threshold,
  512. repeat_last_n: this.config.repeat_last_n,
  513. repeat_penalty: this.config.repeat_penalty,
  514. presence_penalty: this.config.presence_penalty,
  515. frequency_penalty: this.config.frequency_penalty,
  516. dry_multiplier: this.config.dry_multiplier,
  517. dry_base: this.config.dry_base,
  518. dry_allowed_length: this.config.dry_allowed_length,
  519. dry_penalty_last_n: this.config.dry_penalty_last_n,
  520. max_tokens: this.config.max_tokens,
  521. ...(this.config.custom.length ? JSON.parse(this.config.custom) : {}),
  522. ...(this.config.apiKey ? { api_key: this.config.apiKey } : {}),
  523. };
  524. const config = {
  525. controller: abortController,
  526. api_url: BASE_URL,
  527. endpoint: '/chat/completions',
  528. };
  529. for await (const chunk of llama(prompt, params, config)) {
  530. const stop = chunk.data.stop;
  531. const addedContent = chunk.data.choices[0].delta.content;
  532. const lastContent = this.pendingMsg.content || '';
  533. if (addedContent) {
  534. this.pendingMsg = {
  535. id: this.pendingMsg.id,
  536. role: 'assistant',
  537. content: lastContent + addedContent,
  538. };
  539. }
  540. }
  541. StorageUtils.appendMsg(currConvId, this.pendingMsg);
  542. this.fetchConversation();
  543. this.fetchMessages();
  544. setTimeout(() => document.getElementById('msg-input').focus(), 1);
  545. } catch (error) {
  546. if (error.name === 'AbortError') {
  547. // user stopped the generation via stopGeneration() function
  548. StorageUtils.appendMsg(currConvId, this.pendingMsg);
  549. this.fetchConversation();
  550. this.fetchMessages();
  551. } else {
  552. console.error(error);
  553. alert(error);
  554. // pop last user message
  555. const lastUserMsg = StorageUtils.popMsg(currConvId);
  556. this.inputMsg = lastUserMsg ? lastUserMsg.content : '';
  557. }
  558. }
  559. this.pendingMsg = null;
  560. this.isGenerating = false;
  561. this.stopGeneration = () => {};
  562. this.fetchMessages();
  563. },
  564. // message actions
  565. regenerateMsg(msg) {
  566. if (this.isGenerating) return;
  567. // TODO: somehow keep old history (like how ChatGPT has different "tree"). This can be done by adding "sub-conversations" with "subconv-" prefix, and new message will have a list of subconvIds
  568. const currConvId = this.viewingConvId;
  569. StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id);
  570. this.fetchConversation();
  571. this.fetchMessages();
  572. this.generateMessage(currConvId);
  573. },
  574. copyMsg(msg) {
  575. navigator.clipboard.writeText(msg.content);
  576. },
  577. editUserMsgAndRegenerate(msg) {
  578. if (this.isGenerating) return;
  579. const currConvId = this.viewingConvId;
  580. const newContent = msg.content;
  581. this.editingMsg = null;
  582. StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id);
  583. StorageUtils.appendMsg(currConvId, {
  584. id: Date.now(),
  585. role: 'user',
  586. content: newContent,
  587. });
  588. this.fetchConversation();
  589. this.fetchMessages();
  590. this.generateMessage(currConvId);
  591. },
  592. // settings dialog methods
  593. closeAndSaveConfigDialog() {
  594. try {
  595. if (this.config.custom.length) JSON.parse(this.config.custom);
  596. } catch (error) {
  597. alert('Invalid JSON for custom config. Please either fix it or leave it empty.');
  598. return;
  599. }
  600. for (const key of CONFIG_NUMERIC_KEYS) {
  601. if (isNaN(this.config[key]) || this.config[key].toString().trim().length === 0) {
  602. alert(`Invalid number for ${key} (expected an integer or a float)`);
  603. return;
  604. }
  605. this.config[key] = parseFloat(this.config[key]);
  606. }
  607. this.showConfigDialog = false;
  608. StorageUtils.setConfig(this.config);
  609. },
  610. closeAndDiscardConfigDialog() {
  611. this.showConfigDialog = false;
  612. this.config = StorageUtils.getConfig();
  613. },
  614. resetConfigDialog() {
  615. if (window.confirm('Are you sure to reset all settings?')) {
  616. this.config = {...CONFIG_DEFAULT};
  617. }
  618. },
  619. // sync state functions
  620. fetchConversation() {
  621. this.conversations = StorageUtils.getAllConversations();
  622. },
  623. fetchMessages() {
  624. this.messages = StorageUtils.getOneConversation(this.viewingConvId)?.messages ?? [];
  625. },
  626. },
  627. });
  628. mainApp.config.errorHandler = alert;
  629. try {
  630. mainApp.mount('#app');
  631. } catch (err) {
  632. console.error(err);
  633. document.getElementById('app').innerHTML = `<div style="margin:2em auto">
  634. Failed to start app. Please try clearing localStorage and try again.<br/>
  635. <br/>
  636. <button class="btn" onClick="localStorage.clear(); window.location.reload();">Clear localStorage</button>
  637. </div>`;
  638. }
  639. </script>
  640. </body>
  641. </html>