index.html 33 KB

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