1
0

index.html 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  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="key in ['temperature', 'top_k', 'top_p', 'min_p', 'max_tokens']">
  192. <label class="input input-bordered flex items-center gap-2 mb-2">
  193. <b>{{ key }}</b>
  194. <input type="text" class="grow" :placeholder="'Default: ' + (configDefault[key] || 'none')" v-model="config[key]" />
  195. </label>
  196. </template>
  197. <!-- TODO: add more sampling-related configs, please regroup them into different "collapse" sections -->
  198. <div class="collapse collapse-arrow bg-base-200 mb-2">
  199. <input type="checkbox" />
  200. <div class="collapse-title font-bold">Advanced config</div>
  201. <div class="collapse-content">
  202. <label class="form-control mb-2">
  203. <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>
  204. <textarea class="textarea textarea-bordered h-24" placeholder="Example: { &quot;mirostat&quot;: 1, &quot;min_p&quot;: 0.1 }" v-model="config.custom"></textarea>
  205. </label>
  206. </div>
  207. </div>
  208. </div>
  209. <!-- action buttons -->
  210. <div class="modal-action">
  211. <button class="btn" @click="resetConfigDialog">Reset to default</button>
  212. <button class="btn" @click="closeAndDiscardConfigDialog">Close</button>
  213. <button class="btn btn-primary" @click="closeAndSaveConfigDialog">Save and close</button>
  214. </div>
  215. </div>
  216. </dialog>
  217. </div>
  218. <script src="./deps_markdown-it.js"></script>
  219. <script type="module">
  220. import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
  221. import { llama } from './completion.js';
  222. const isString = (x) => !!x.toLowerCase;
  223. const isNumeric = (n) => !isString(n) && !isNaN(n);
  224. const BASE_URL = localStorage.getItem('base') // for debugging
  225. || (new URL('.', document.baseURI).href).toString(); // for production
  226. const CONFIG_DEFAULT = {
  227. // 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.
  228. apiKey: '',
  229. systemMessage: 'You are a helpful assistant.',
  230. // make sure these default values are in sync with `common.h`
  231. temperature: 0.8,
  232. top_k: 40,
  233. top_p: 0.95,
  234. min_p: 0.05,
  235. max_tokens: -1,
  236. custom: '', // custom json-stringified object
  237. };
  238. // config keys having numeric value (i.e. temperature, top_k, top_p, etc)
  239. const CONFIG_NUMERIC_KEYS = Object.entries(CONFIG_DEFAULT).filter(e => isNumeric(e[1])).map(e => e[0]);
  240. // list of themes supported by daisyui
  241. 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'];
  242. // markdown support
  243. const VueMarkdown = defineComponent(
  244. (props) => {
  245. const md = shallowRef(new markdownit(props.options ?? { breaks: true }));
  246. for (const plugin of props.plugins ?? []) {
  247. md.value.use(plugin);
  248. }
  249. const content = computed(() => md.value.render(props.source));
  250. return () => h("div", { innerHTML: content.value });
  251. },
  252. { props: ["source", "options", "plugins"] }
  253. );
  254. // coversations is stored in localStorage
  255. // format: { [convId]: { id: string, lastModified: number, messages: [...] } }
  256. // convId is a string prefixed with 'conv-'
  257. const StorageUtils = {
  258. // manage conversations
  259. getAllConversations() {
  260. const res = [];
  261. for (const key in localStorage) {
  262. if (key.startsWith('conv-')) {
  263. res.push(JSON.parse(localStorage.getItem(key)));
  264. }
  265. }
  266. res.sort((a, b) => b.lastModified - a.lastModified);
  267. return res;
  268. },
  269. // can return null if convId does not exist
  270. getOneConversation(convId) {
  271. return JSON.parse(localStorage.getItem(convId) || 'null');
  272. },
  273. // if convId does not exist, create one
  274. appendMsg(convId, msg) {
  275. if (msg.content === null) return;
  276. const conv = StorageUtils.getOneConversation(convId) || {
  277. id: convId,
  278. lastModified: Date.now(),
  279. messages: [],
  280. };
  281. conv.messages.push(msg);
  282. conv.lastModified = Date.now();
  283. localStorage.setItem(convId, JSON.stringify(conv));
  284. },
  285. getNewConvId() {
  286. return `conv-${Date.now()}`;
  287. },
  288. remove(convId) {
  289. localStorage.removeItem(convId);
  290. },
  291. filterAndKeepMsgs(convId, predicate) {
  292. const conv = StorageUtils.getOneConversation(convId);
  293. if (!conv) return;
  294. conv.messages = conv.messages.filter(predicate);
  295. conv.lastModified = Date.now();
  296. localStorage.setItem(convId, JSON.stringify(conv));
  297. },
  298. popMsg(convId) {
  299. const conv = StorageUtils.getOneConversation(convId);
  300. if (!conv) return;
  301. const msg = conv.messages.pop();
  302. conv.lastModified = Date.now();
  303. localStorage.setItem(convId, JSON.stringify(conv));
  304. return msg;
  305. },
  306. // manage config
  307. getConfig() {
  308. const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
  309. // to prevent breaking changes in the future, we always provide default value for missing keys
  310. return {
  311. ...CONFIG_DEFAULT,
  312. ...savedVal,
  313. };
  314. },
  315. setConfig(config) {
  316. localStorage.setItem('config', JSON.stringify(config));
  317. },
  318. getTheme() {
  319. return localStorage.getItem('theme') || 'auto';
  320. },
  321. setTheme(theme) {
  322. if (theme === 'auto') {
  323. localStorage.removeItem('theme');
  324. } else {
  325. localStorage.setItem('theme', theme);
  326. }
  327. },
  328. };
  329. // scroll to bottom of chat messages
  330. // if requiresNearBottom is true, only auto-scroll if user is near bottom
  331. const chatScrollToBottom = (requiresNearBottom) => {
  332. const msgListElem = document.getElementById('messages-list');
  333. const spaceToBottom = msgListElem.scrollHeight - msgListElem.scrollTop - msgListElem.clientHeight;
  334. if (!requiresNearBottom || (spaceToBottom < 100)) {
  335. setTimeout(() => msgListElem.scrollTo({ top: msgListElem.scrollHeight }), 1);
  336. }
  337. };
  338. const mainApp = createApp({
  339. components: {
  340. VueMarkdown,
  341. },
  342. data() {
  343. return {
  344. conversations: StorageUtils.getAllConversations(),
  345. messages: [], // { id: number, role: 'user' | 'assistant', content: string }
  346. viewingConvId: StorageUtils.getNewConvId(),
  347. inputMsg: '',
  348. isGenerating: false,
  349. pendingMsg: null, // the on-going message from assistant
  350. stopGeneration: () => {},
  351. selectedTheme: StorageUtils.getTheme(),
  352. config: StorageUtils.getConfig(),
  353. showConfigDialog: false,
  354. editingMsg: null,
  355. // const
  356. themes: THEMES,
  357. configDefault: {...CONFIG_DEFAULT},
  358. }
  359. },
  360. computed: {},
  361. mounted() {
  362. document.getElementById('app').classList.remove('opacity-0'); // show app
  363. // scroll to the bottom when the pending message height is updated
  364. const pendingMsgElem = document.getElementById('pending-msg');
  365. const resizeObserver = new ResizeObserver(() => {
  366. if (this.isGenerating) chatScrollToBottom(true);
  367. });
  368. resizeObserver.observe(pendingMsgElem);
  369. },
  370. methods: {
  371. setSelectedTheme(theme) {
  372. this.selectedTheme = theme;
  373. StorageUtils.setTheme(theme);
  374. },
  375. newConversation() {
  376. if (this.isGenerating) return;
  377. this.viewingConvId = StorageUtils.getNewConvId();
  378. this.editingMsg = null;
  379. this.fetchMessages();
  380. chatScrollToBottom();
  381. },
  382. setViewingConv(convId) {
  383. if (this.isGenerating) return;
  384. this.viewingConvId = convId;
  385. this.editingMsg = null;
  386. this.fetchMessages();
  387. chatScrollToBottom();
  388. },
  389. deleteConv(convId) {
  390. if (this.isGenerating) return;
  391. if (window.confirm('Are you sure to delete this conversation?')) {
  392. StorageUtils.remove(convId);
  393. if (this.viewingConvId === convId) {
  394. this.viewingConvId = StorageUtils.getNewConvId();
  395. this.editingMsg = null;
  396. }
  397. this.fetchConversation();
  398. this.fetchMessages();
  399. }
  400. },
  401. async sendMessage() {
  402. if (!this.inputMsg) return;
  403. const currConvId = this.viewingConvId;
  404. StorageUtils.appendMsg(currConvId, {
  405. id: Date.now(),
  406. role: 'user',
  407. content: this.inputMsg,
  408. });
  409. this.fetchConversation();
  410. this.fetchMessages();
  411. this.inputMsg = '';
  412. this.editingMsg = null;
  413. this.generateMessage(currConvId);
  414. chatScrollToBottom();
  415. },
  416. async generateMessage(currConvId) {
  417. if (this.isGenerating) return;
  418. this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null };
  419. this.isGenerating = true;
  420. this.editingMsg = null;
  421. try {
  422. const abortController = new AbortController();
  423. this.stopGeneration = () => abortController.abort();
  424. const params = {
  425. messages: [
  426. { role: 'system', content: this.config.systemMessage },
  427. ...this.messages,
  428. ],
  429. stream: true,
  430. cache_prompt: true,
  431. temperature: this.config.temperature,
  432. top_k: this.config.top_k,
  433. top_p: this.config.top_p,
  434. max_tokens: this.config.max_tokens,
  435. ...(this.config.custom.length ? JSON.parse(this.config.custom) : {}),
  436. ...(this.config.apiKey ? { api_key: this.config.apiKey } : {}),
  437. };
  438. const config = {
  439. controller: abortController,
  440. api_url: BASE_URL,
  441. endpoint: '/chat/completions',
  442. };
  443. for await (const chunk of llama(prompt, params, config)) {
  444. const stop = chunk.data.stop;
  445. const addedContent = chunk.data.choices[0].delta.content;
  446. const lastContent = this.pendingMsg.content || '';
  447. if (addedContent) {
  448. this.pendingMsg = {
  449. id: this.pendingMsg.id,
  450. role: 'assistant',
  451. content: lastContent + addedContent,
  452. };
  453. }
  454. }
  455. StorageUtils.appendMsg(currConvId, this.pendingMsg);
  456. this.fetchConversation();
  457. this.fetchMessages();
  458. setTimeout(() => document.getElementById('msg-input').focus(), 1);
  459. } catch (error) {
  460. if (error.name === 'AbortError') {
  461. // user stopped the generation via stopGeneration() function
  462. StorageUtils.appendMsg(currConvId, this.pendingMsg);
  463. this.fetchConversation();
  464. this.fetchMessages();
  465. } else {
  466. console.error(error);
  467. alert(error);
  468. // pop last user message
  469. const lastUserMsg = StorageUtils.popMsg(currConvId);
  470. this.inputMsg = lastUserMsg ? lastUserMsg.content : '';
  471. }
  472. }
  473. this.pendingMsg = null;
  474. this.isGenerating = false;
  475. this.stopGeneration = () => {};
  476. this.fetchMessages();
  477. },
  478. // message actions
  479. regenerateMsg(msg) {
  480. if (this.isGenerating) return;
  481. // 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
  482. const currConvId = this.viewingConvId;
  483. StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id);
  484. this.fetchConversation();
  485. this.fetchMessages();
  486. this.generateMessage(currConvId);
  487. },
  488. copyMsg(msg) {
  489. navigator.clipboard.writeText(msg.content);
  490. },
  491. editUserMsgAndRegenerate(msg) {
  492. if (this.isGenerating) return;
  493. const currConvId = this.viewingConvId;
  494. const newContent = msg.content;
  495. this.editingMsg = null;
  496. StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id);
  497. StorageUtils.appendMsg(currConvId, {
  498. id: Date.now(),
  499. role: 'user',
  500. content: newContent,
  501. });
  502. this.fetchConversation();
  503. this.fetchMessages();
  504. this.generateMessage(currConvId);
  505. },
  506. // settings dialog methods
  507. closeAndSaveConfigDialog() {
  508. try {
  509. if (this.config.custom.length) JSON.parse(this.config.custom);
  510. } catch (error) {
  511. alert('Invalid JSON for custom config. Please either fix it or leave it empty.');
  512. return;
  513. }
  514. for (const key of CONFIG_NUMERIC_KEYS) {
  515. if (isNaN(this.config[key]) || this.config[key].toString().trim().length === 0) {
  516. alert(`Invalid number for ${key} (expected an integer or a float)`);
  517. return;
  518. }
  519. this.config[key] = parseFloat(this.config[key]);
  520. }
  521. this.showConfigDialog = false;
  522. StorageUtils.setConfig(this.config);
  523. },
  524. closeAndDiscardConfigDialog() {
  525. this.showConfigDialog = false;
  526. this.config = StorageUtils.getConfig();
  527. },
  528. resetConfigDialog() {
  529. if (window.confirm('Are you sure to reset all settings?')) {
  530. this.config = {...CONFIG_DEFAULT};
  531. }
  532. },
  533. // sync state functions
  534. fetchConversation() {
  535. this.conversations = StorageUtils.getAllConversations();
  536. },
  537. fetchMessages() {
  538. this.messages = StorageUtils.getOneConversation(this.viewingConvId)?.messages ?? [];
  539. },
  540. },
  541. });
  542. mainApp.config.errorHandler = alert;
  543. try {
  544. mainApp.mount('#app');
  545. } catch (err) {
  546. console.error(err);
  547. document.getElementById('app').innerHTML = `<div style="margin:2em auto">
  548. Failed to start app. Please try clearing localStorage and try again.<br/>
  549. <br/>
  550. <button class="btn" onClick="localStorage.clear(); window.location.reload();">Clear localStorage</button>
  551. </div>`;
  552. }
  553. </script>
  554. </body>
  555. </html>