|
7 | 7 |
|
8 | 8 | <!-- Note: dependencies can de updated using ./deps.sh script --> |
9 | 9 | <link href="./deps_daisyui.min.css" rel="stylesheet" type="text/css" /> |
| 10 | + <!-- Note for daisyui: because we're using a subset of daisyui via CDN, many things won't be included --> |
10 | 11 | <script src="./deps_tailwindcss.js"></script> |
11 | | - <script> |
12 | | - tailwind.config = {}; |
13 | | - </script> |
14 | 12 | <style type="text/tailwindcss"> |
15 | 13 | .markdown { |
16 | 14 | h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; } |
17 | 15 | pre { @apply whitespace-pre-wrap; } |
18 | | - /* TODO: fix table */ |
| 16 | + /* TODO: fix markdown table */ |
19 | 17 | } |
20 | 18 | </style> |
21 | 19 | </head> |
22 | 20 |
|
23 | 21 | <body> |
24 | | - <div id="app"> |
| 22 | + <div id="app" class="flex flex-row"> |
| 23 | + <div class="flex flex-col bg-black bg-opacity-5 w-64 py-8 px-4 h-screen overflow-y-auto"> |
| 24 | + <h2 class="font-bold mb-4 ml-4">Conversations</h2> |
| 25 | + <div :class="{ |
| 26 | + 'btn btn-ghost justify-start': true, |
| 27 | + 'btn-active': messages.length === 0, |
| 28 | + }" @click="newConversation"> |
| 29 | + + New conversation |
| 30 | + </div> |
| 31 | + <div v-for="conv in conversations" :class="{ |
| 32 | + 'btn btn-ghost justify-start font-normal': true, |
| 33 | + 'btn-active': conv.id === viewingConvId, |
| 34 | + }" @click="setViewingConv(conv.id)"> |
| 35 | + <span class="truncate">{{ conv.messages[0].content }}</span> |
| 36 | + </div> |
| 37 | + <div class="text-center text-xs opacity-40 mt-auto mx-4"> |
| 38 | + Conversations are saved to browser's localStorage |
| 39 | + </div> |
| 40 | + </div> |
| 41 | + |
25 | 42 | <div class="flex flex-col w-screen h-screen max-w-screen-md px-8 mx-auto"> |
26 | 43 | <!-- header --> |
27 | 44 | <div class="flex flex-row items-center"> |
28 | 45 | <div class="grow text-2xl font-bold mt-8 mb-6"> |
29 | 46 | 🦙 llama.cpp - chat |
30 | 47 | </div> |
31 | | - <div> |
| 48 | + <div class="flex items-center"> |
| 49 | + <button v-if="messages.length > 0" class="btn" @click="deleteConv(viewingConvId)"> |
| 50 | + <!-- delete conversation button --> |
| 51 | + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16"> |
| 52 | + <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"/> |
| 53 | + <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"/> |
| 54 | + </svg> |
| 55 | + </button> |
| 56 | + |
32 | 57 | <!-- theme controller is copied from https://daisyui.com/components/theme-controller/ --> |
| 58 | + <!-- TODO: memorize this theme selection in localStorage, maybe also add "auto" option --> |
33 | 59 | <div class="dropdown dropdown-end dropdown-bottom"> |
34 | 60 | <div tabindex="0" role="button" class="btn m-1"> |
35 | 61 | Theme |
|
42 | 68 | <input |
43 | 69 | type="radio" |
44 | 70 | name="theme-dropdown" |
45 | | - class="theme-controller btn btn-sm btn-block btn-ghost justify-start" |
| 71 | + class="theme-controller btn btn-sm btn-block w-full btn-ghost justify-start" |
46 | 72 | :aria-label="theme" |
47 | 73 | :value="theme" /> |
48 | 74 | </li> |
|
58 | 84 | {{ messages.length === 0 ? 'Send a message to start' : '' }} |
59 | 85 | </div> |
60 | 86 | <div v-for="msg in messages" :class="{ |
61 | | - 'chat': true, |
| 87 | + 'chat group': true, |
62 | 88 | 'chat-start': msg.role !== 'user', |
63 | 89 | 'chat-end': msg.role === 'user', |
64 | 90 | }"> |
|
68 | 94 | }"> |
69 | 95 | <vue-markdown :source="msg.content" /> |
70 | 96 | </div> |
| 97 | + <div v-if="msg.role === 'user'" class="badge cursor-pointer opacity-0 group-hover:opacity-100"> |
| 98 | + Edit |
| 99 | + </div> |
71 | 100 | </div> |
72 | 101 |
|
73 | 102 | <!-- pending assistant message --> |
|
86 | 115 | placeholder="Type a message..." |
87 | 116 | v-model="inputMsg" |
88 | 117 | @keydown.enter="sendMessage" |
89 | | - v-bind:disabled="state !== 'ready'" |
| 118 | + v-bind:disabled="isGenerating" |
90 | 119 | id="msg-input" |
91 | 120 | ></textarea> |
92 | | - <button class="btn btn-primary ml-2" @click="sendMessage" v-bind:disabled="state !== 'ready'">Send</button> |
| 121 | + <button class="btn btn-primary ml-2" @click="sendMessage" v-bind:disabled="isGenerating">Send</button> |
93 | 122 | </div> |
94 | 123 | </div> |
95 | 124 | </div> |
|
99 | 128 | import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js'; |
100 | 129 | import { llama } from './completion.js'; |
101 | 130 |
|
102 | | - const BASE_URL = (new URL('.', document.baseURI).href).toString(); |
| 131 | + const BASE_URL = localStorage.getItem('base') // for debugging |
| 132 | + || (new URL('.', document.baseURI).href).toString(); // for production |
103 | 133 |
|
104 | 134 | // markdown support |
105 | 135 | const VueMarkdown = defineComponent( |
|
114 | 144 | { props: ["source", "options", "plugins"] } |
115 | 145 | ); |
116 | 146 |
|
| 147 | + // storage class |
| 148 | + // coversations is stored in localStorage |
| 149 | + // format: { [convId]: { id: string, lastModified: number, messages: [...] } } |
| 150 | + // convId is a string prefixed with 'conv-' |
| 151 | + const Conversations = { |
| 152 | + getAll() { |
| 153 | + const res = []; |
| 154 | + for (const key in localStorage) { |
| 155 | + if (key.startsWith('conv-')) { |
| 156 | + res.push(JSON.parse(localStorage.getItem(key))); |
| 157 | + } |
| 158 | + } |
| 159 | + res.sort((a, b) => b.lastModified - a.lastModified); |
| 160 | + return res; |
| 161 | + }, |
| 162 | + // can return null if convId does not exist |
| 163 | + getOne(convId) { |
| 164 | + return JSON.parse(localStorage.getItem(convId) || 'null'); |
| 165 | + }, |
| 166 | + // if convId does not exist, create one |
| 167 | + appendMsg(convId, msg) { |
| 168 | + const conv = Conversations.getOne(convId) || { |
| 169 | + id: convId, |
| 170 | + lastModified: Date.now(), |
| 171 | + messages: [], |
| 172 | + }; |
| 173 | + conv.messages.push(msg); |
| 174 | + conv.lastModified = Date.now(); |
| 175 | + localStorage.setItem(convId, JSON.stringify(conv)); |
| 176 | + }, |
| 177 | + getNewConvId() { |
| 178 | + return `conv-${Date.now()}`; |
| 179 | + }, |
| 180 | + remove(convId) { |
| 181 | + localStorage.removeItem(convId); |
| 182 | + }, |
| 183 | + }; |
| 184 | + |
| 185 | + // format of message: { id: number, role: 'user' | 'assistant', content: string } |
117 | 186 | createApp({ |
118 | 187 | components: { |
119 | 188 | VueMarkdown, |
120 | 189 | }, |
121 | 190 | data() { |
122 | 191 | return { |
| 192 | + conversations: Conversations.getAll(), |
123 | 193 | messages: [], |
| 194 | + viewingConvId: Conversations.getNewConvId(), |
124 | 195 | inputMsg: '', |
125 | | - state: 'ready', |
| 196 | + isGenerating: false, |
126 | 197 | pendingMsg: null, // the on-going message from assistant |
127 | 198 | abortController: null, |
128 | 199 | // const |
129 | 200 | themes: ['light', 'dark', 'retro', 'cyberpunk', 'aqua', 'valentine', 'synthwave'], |
130 | 201 | } |
131 | 202 | }, |
| 203 | + computed: {}, |
132 | 204 | mounted() { |
133 | 205 | // scroll to the bottom when the pending message height is updated |
134 | 206 | const pendingMsgElem = document.getElementById('pending-msg'); |
135 | 207 | const msgListElem = document.getElementById('messages-list'); |
136 | 208 | const resizeObserver = new ResizeObserver(() => { |
137 | | - if (this.state === 'generating') { |
| 209 | + if (this.isGenerating) { |
138 | 210 | msgListElem.scrollTo({ top: msgListElem.scrollHeight }); |
139 | 211 | } |
140 | 212 | }); |
141 | 213 | resizeObserver.observe(pendingMsgElem); |
142 | 214 | }, |
143 | 215 | methods: { |
| 216 | + newConversation() { |
| 217 | + if (this.isGenerating) return; |
| 218 | + this.viewingConvId = Conversations.getNewConvId(); |
| 219 | + this.fetchMessages(); |
| 220 | + }, |
| 221 | + setViewingConv(convId) { |
| 222 | + if (this.isGenerating) return; |
| 223 | + this.viewingConvId = convId; |
| 224 | + this.fetchMessages(); |
| 225 | + }, |
| 226 | + deleteConv(convId) { |
| 227 | + if (this.isGenerating) return; |
| 228 | + if (window.confirm('Are you sure to delete this conversation?')) { |
| 229 | + Conversations.remove(convId); |
| 230 | + if (this.viewingConvId === convId) { |
| 231 | + this.viewingConvId = Conversations.getNewConvId(); |
| 232 | + } |
| 233 | + this.fetchConversation(); |
| 234 | + this.fetchMessages(); |
| 235 | + } |
| 236 | + }, |
144 | 237 | async sendMessage() { |
145 | 238 | if (!this.inputMsg) return; |
| 239 | + const currConvId = this.viewingConvId; |
| 240 | + |
| 241 | + Conversations.appendMsg(currConvId, { |
| 242 | + id: Date.now(), |
| 243 | + role: 'user', |
| 244 | + content: this.inputMsg, |
| 245 | + }); |
| 246 | + this.fetchConversation(); |
| 247 | + this.fetchMessages(); |
146 | 248 |
|
147 | | - this.messages = [ |
148 | | - ...this.messages, |
149 | | - { role: 'user', content: this.inputMsg }, |
150 | | - ]; |
151 | 249 | this.inputMsg = ''; |
152 | | - this.pendingMsg = { role: 'assistant', content: null }; |
153 | | - this.state = 'generating'; |
| 250 | + this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null }; |
| 251 | + this.isGenerating = true; |
154 | 252 |
|
155 | 253 | try { |
156 | 254 | this.abortController = new AbortController(); |
|
169 | 267 | const addedContent = chunk.data.choices[0].delta.content; |
170 | 268 | const lastContent = this.pendingMsg.content || ''; |
171 | 269 | if (addedContent) { |
172 | | - this.pendingMsg = { role: 'assistant', content: lastContent + addedContent }; |
| 270 | + this.pendingMsg = { |
| 271 | + id: this.pendingMsg.id, |
| 272 | + role: 'assistant', |
| 273 | + content: lastContent + addedContent, |
| 274 | + }; |
173 | 275 | } |
174 | 276 | } |
175 | | - this.messages = [...this.messages, this.pendingMsg]; |
| 277 | + |
| 278 | + Conversations.appendMsg(currConvId, this.pendingMsg); |
| 279 | + this.fetchConversation(); |
| 280 | + this.fetchMessages(); |
176 | 281 | this.pendingMsg = null; |
177 | | - this.state = 'ready'; |
| 282 | + this.isGenerating = false; |
178 | 283 | setTimeout(() => { |
179 | 284 | document.getElementById('msg-input').focus(); |
180 | 285 | }, 1); |
181 | 286 | } catch (error) { |
182 | 287 | console.error(error); |
183 | 288 | alert(error); |
184 | 289 | this.pendingMsg = null; |
185 | | - this.state = 'ready'; |
| 290 | + this.isGenerating = false; |
186 | 291 | } |
187 | 292 | }, |
| 293 | + |
| 294 | + // sync state functions |
| 295 | + fetchConversation() { |
| 296 | + this.conversations = Conversations.getAll(); |
| 297 | + }, |
| 298 | + fetchMessages() { |
| 299 | + this.messages = Conversations.getOne(this.viewingConvId)?.messages ?? []; |
| 300 | + }, |
188 | 301 | }, |
189 | | - }).mount('#app') |
| 302 | + }).mount('#app'); |
190 | 303 | </script> |
191 | 304 | </body> |
192 | 305 |
|
|
0 commit comments