Skip to content

Commit 9719450

Browse files
committed
add conversation history, save to localStorage
1 parent 7f3daf0 commit 9719450

File tree

3 files changed

+172
-61
lines changed

3 files changed

+172
-61
lines changed

examples/server/deps.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ echo "download js bundle files"
99

1010
# Note for contributors: Always pin to a specific version "maj.min.patch" to avoid breaking the CI
1111

12-
curl -L https://cdn.tailwindcss.com/3.4.14?plugins=forms,typography > $PUBLIC/deps_tailwindcss.js
12+
curl -L https://cdn.tailwindcss.com/3.4.14 > $PUBLIC/deps_tailwindcss.js
1313
echo >> $PUBLIC/deps_tailwindcss.js # add newline
1414

1515
curl -L https://cdnjs.cloudflare.com/ajax/libs/daisyui/4.12.14/styled.min.css > $PUBLIC/deps_daisyui.min.css

examples/server/public/deps_tailwindcss.js

Lines changed: 34 additions & 36 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/server/public/index.html

Lines changed: 137 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,55 @@
77

88
<!-- Note: dependencies can de updated using ./deps.sh script -->
99
<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 -->
1011
<script src="./deps_tailwindcss.js"></script>
11-
<script>
12-
tailwind.config = {};
13-
</script>
1412
<style type="text/tailwindcss">
1513
.markdown {
1614
h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
1715
pre { @apply whitespace-pre-wrap; }
18-
/* TODO: fix table */
16+
/* TODO: fix markdown table */
1917
}
2018
</style>
2119
</head>
2220

2321
<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+
2542
<div class="flex flex-col w-screen h-screen max-w-screen-md px-8 mx-auto">
2643
<!-- header -->
2744
<div class="flex flex-row items-center">
2845
<div class="grow text-2xl font-bold mt-8 mb-6">
2946
🦙 llama.cpp - chat
3047
</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+
3257
<!-- theme controller is copied from https://daisyui.com/components/theme-controller/ -->
58+
<!-- TODO: memorize this theme selection in localStorage, maybe also add "auto" option -->
3359
<div class="dropdown dropdown-end dropdown-bottom">
3460
<div tabindex="0" role="button" class="btn m-1">
3561
Theme
@@ -42,7 +68,7 @@
4268
<input
4369
type="radio"
4470
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"
4672
:aria-label="theme"
4773
:value="theme" />
4874
</li>
@@ -58,7 +84,7 @@
5884
{{ messages.length === 0 ? 'Send a message to start' : '' }}
5985
</div>
6086
<div v-for="msg in messages" :class="{
61-
'chat': true,
87+
'chat group': true,
6288
'chat-start': msg.role !== 'user',
6389
'chat-end': msg.role === 'user',
6490
}">
@@ -68,6 +94,9 @@
6894
}">
6995
<vue-markdown :source="msg.content" />
7096
</div>
97+
<div v-if="msg.role === 'user'" class="badge cursor-pointer opacity-0 group-hover:opacity-100">
98+
Edit
99+
</div>
71100
</div>
72101

73102
<!-- pending assistant message -->
@@ -86,10 +115,10 @@
86115
placeholder="Type a message..."
87116
v-model="inputMsg"
88117
@keydown.enter="sendMessage"
89-
v-bind:disabled="state !== 'ready'"
118+
v-bind:disabled="isGenerating"
90119
id="msg-input"
91120
></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>
93122
</div>
94123
</div>
95124
</div>
@@ -99,7 +128,8 @@
99128
import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
100129
import { llama } from './completion.js';
101130

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
103133

104134
// markdown support
105135
const VueMarkdown = defineComponent(
@@ -114,43 +144,111 @@
114144
{ props: ["source", "options", "plugins"] }
115145
);
116146

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 }
117186
createApp({
118187
components: {
119188
VueMarkdown,
120189
},
121190
data() {
122191
return {
192+
conversations: Conversations.getAll(),
123193
messages: [],
194+
viewingConvId: Conversations.getNewConvId(),
124195
inputMsg: '',
125-
state: 'ready',
196+
isGenerating: false,
126197
pendingMsg: null, // the on-going message from assistant
127198
abortController: null,
128199
// const
129200
themes: ['light', 'dark', 'retro', 'cyberpunk', 'aqua', 'valentine', 'synthwave'],
130201
}
131202
},
203+
computed: {},
132204
mounted() {
133205
// scroll to the bottom when the pending message height is updated
134206
const pendingMsgElem = document.getElementById('pending-msg');
135207
const msgListElem = document.getElementById('messages-list');
136208
const resizeObserver = new ResizeObserver(() => {
137-
if (this.state === 'generating') {
209+
if (this.isGenerating) {
138210
msgListElem.scrollTo({ top: msgListElem.scrollHeight });
139211
}
140212
});
141213
resizeObserver.observe(pendingMsgElem);
142214
},
143215
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+
},
144237
async sendMessage() {
145238
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();
146248

147-
this.messages = [
148-
...this.messages,
149-
{ role: 'user', content: this.inputMsg },
150-
];
151249
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;
154252

155253
try {
156254
this.abortController = new AbortController();
@@ -169,24 +267,39 @@
169267
const addedContent = chunk.data.choices[0].delta.content;
170268
const lastContent = this.pendingMsg.content || '';
171269
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+
};
173275
}
174276
}
175-
this.messages = [...this.messages, this.pendingMsg];
277+
278+
Conversations.appendMsg(currConvId, this.pendingMsg);
279+
this.fetchConversation();
280+
this.fetchMessages();
176281
this.pendingMsg = null;
177-
this.state = 'ready';
282+
this.isGenerating = false;
178283
setTimeout(() => {
179284
document.getElementById('msg-input').focus();
180285
}, 1);
181286
} catch (error) {
182287
console.error(error);
183288
alert(error);
184289
this.pendingMsg = null;
185-
this.state = 'ready';
290+
this.isGenerating = false;
186291
}
187292
},
293+
294+
// sync state functions
295+
fetchConversation() {
296+
this.conversations = Conversations.getAll();
297+
},
298+
fetchMessages() {
299+
this.messages = Conversations.getOne(this.viewingConvId)?.messages ?? [];
300+
},
188301
},
189-
}).mount('#app')
302+
}).mount('#app');
190303
</script>
191304
</body>
192305

0 commit comments

Comments
 (0)