add conversation history, save to localStorage

This commit is contained in:
Xuan Son Nguyen 2024-11-05 16:39:24 +01:00
parent 7f3daf09f3
commit 9719450232
3 changed files with 172 additions and 61 deletions

View file

@ -9,7 +9,7 @@ echo "download js bundle files"
# Note for contributors: Always pin to a specific version "maj.min.patch" to avoid breaking the CI
curl -L https://cdn.tailwindcss.com/3.4.14?plugins=forms,typography > $PUBLIC/deps_tailwindcss.js
curl -L https://cdn.tailwindcss.com/3.4.14 > $PUBLIC/deps_tailwindcss.js
echo >> $PUBLIC/deps_tailwindcss.js # add newline
curl -L https://cdnjs.cloudflare.com/ajax/libs/daisyui/4.12.14/styled.min.css > $PUBLIC/deps_daisyui.min.css

File diff suppressed because one or more lines are too long

View file

@ -7,29 +7,55 @@
<!-- Note: dependencies can de updated using ./deps.sh script -->
<link href="./deps_daisyui.min.css" rel="stylesheet" type="text/css" />
<!-- Note for daisyui: because we're using a subset of daisyui via CDN, many things won't be included -->
<script src="./deps_tailwindcss.js"></script>
<script>
tailwind.config = {};
</script>
<style type="text/tailwindcss">
.markdown {
h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
pre { @apply whitespace-pre-wrap; }
/* TODO: fix table */
/* TODO: fix markdown table */
}
</style>
</head>
<body>
<div id="app">
<div id="app" class="flex flex-row">
<div class="flex flex-col bg-black bg-opacity-5 w-64 py-8 px-4 h-screen overflow-y-auto">
<h2 class="font-bold mb-4 ml-4">Conversations</h2>
<div :class="{
'btn btn-ghost justify-start': true,
'btn-active': messages.length === 0,
}" @click="newConversation">
+ New conversation
</div>
<div v-for="conv in conversations" :class="{
'btn btn-ghost justify-start font-normal': true,
'btn-active': conv.id === viewingConvId,
}" @click="setViewingConv(conv.id)">
<span class="truncate">{{ conv.messages[0].content }}</span>
</div>
<div class="text-center text-xs opacity-40 mt-auto mx-4">
Conversations are saved to browser's localStorage
</div>
</div>
<div class="flex flex-col w-screen h-screen max-w-screen-md px-8 mx-auto">
<!-- header -->
<div class="flex flex-row items-center">
<div class="grow text-2xl font-bold mt-8 mb-6">
🦙 llama.cpp - chat
</div>
<div>
<div class="flex items-center">
<button v-if="messages.length > 0" class="btn" @click="deleteConv(viewingConvId)">
<!-- delete conversation button -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<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"/>
<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"/>
</svg>
</button>
<!-- theme controller is copied from https://daisyui.com/components/theme-controller/ -->
<!-- TODO: memorize this theme selection in localStorage, maybe also add "auto" option -->
<div class="dropdown dropdown-end dropdown-bottom">
<div tabindex="0" role="button" class="btn m-1">
Theme
@ -42,7 +68,7 @@
<input
type="radio"
name="theme-dropdown"
class="theme-controller btn btn-sm btn-block btn-ghost justify-start"
class="theme-controller btn btn-sm btn-block w-full btn-ghost justify-start"
:aria-label="theme"
:value="theme" />
</li>
@ -58,7 +84,7 @@
{{ messages.length === 0 ? 'Send a message to start' : '' }}
</div>
<div v-for="msg in messages" :class="{
'chat': true,
'chat group': true,
'chat-start': msg.role !== 'user',
'chat-end': msg.role === 'user',
}">
@ -68,6 +94,9 @@
}">
<vue-markdown :source="msg.content" />
</div>
<div v-if="msg.role === 'user'" class="badge cursor-pointer opacity-0 group-hover:opacity-100">
Edit
</div>
</div>
<!-- pending assistant message -->
@ -86,10 +115,10 @@
placeholder="Type a message..."
v-model="inputMsg"
@keydown.enter="sendMessage"
v-bind:disabled="state !== 'ready'"
v-bind:disabled="isGenerating"
id="msg-input"
></textarea>
<button class="btn btn-primary ml-2" @click="sendMessage" v-bind:disabled="state !== 'ready'">Send</button>
<button class="btn btn-primary ml-2" @click="sendMessage" v-bind:disabled="isGenerating">Send</button>
</div>
</div>
</div>
@ -99,7 +128,8 @@
import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
import { llama } from './completion.js';
const BASE_URL = (new URL('.', document.baseURI).href).toString();
const BASE_URL = localStorage.getItem('base') // for debugging
|| (new URL('.', document.baseURI).href).toString(); // for production
// markdown support
const VueMarkdown = defineComponent(
@ -114,43 +144,111 @@
{ props: ["source", "options", "plugins"] }
);
// storage class
// coversations is stored in localStorage
// format: { [convId]: { id: string, lastModified: number, messages: [...] } }
// convId is a string prefixed with 'conv-'
const Conversations = {
getAll() {
const res = [];
for (const key in localStorage) {
if (key.startsWith('conv-')) {
res.push(JSON.parse(localStorage.getItem(key)));
}
}
res.sort((a, b) => b.lastModified - a.lastModified);
return res;
},
// can return null if convId does not exist
getOne(convId) {
return JSON.parse(localStorage.getItem(convId) || 'null');
},
// if convId does not exist, create one
appendMsg(convId, msg) {
const conv = Conversations.getOne(convId) || {
id: convId,
lastModified: Date.now(),
messages: [],
};
conv.messages.push(msg);
conv.lastModified = Date.now();
localStorage.setItem(convId, JSON.stringify(conv));
},
getNewConvId() {
return `conv-${Date.now()}`;
},
remove(convId) {
localStorage.removeItem(convId);
},
};
// format of message: { id: number, role: 'user' | 'assistant', content: string }
createApp({
components: {
VueMarkdown,
},
data() {
return {
conversations: Conversations.getAll(),
messages: [],
viewingConvId: Conversations.getNewConvId(),
inputMsg: '',
state: 'ready',
isGenerating: false,
pendingMsg: null, // the on-going message from assistant
abortController: null,
// const
themes: ['light', 'dark', 'retro', 'cyberpunk', 'aqua', 'valentine', 'synthwave'],
}
},
computed: {},
mounted() {
// scroll to the bottom when the pending message height is updated
const pendingMsgElem = document.getElementById('pending-msg');
const msgListElem = document.getElementById('messages-list');
const resizeObserver = new ResizeObserver(() => {
if (this.state === 'generating') {
if (this.isGenerating) {
msgListElem.scrollTo({ top: msgListElem.scrollHeight });
}
});
resizeObserver.observe(pendingMsgElem);
},
methods: {
newConversation() {
if (this.isGenerating) return;
this.viewingConvId = Conversations.getNewConvId();
this.fetchMessages();
},
setViewingConv(convId) {
if (this.isGenerating) return;
this.viewingConvId = convId;
this.fetchMessages();
},
deleteConv(convId) {
if (this.isGenerating) return;
if (window.confirm('Are you sure to delete this conversation?')) {
Conversations.remove(convId);
if (this.viewingConvId === convId) {
this.viewingConvId = Conversations.getNewConvId();
}
this.fetchConversation();
this.fetchMessages();
}
},
async sendMessage() {
if (!this.inputMsg) return;
const currConvId = this.viewingConvId;
Conversations.appendMsg(currConvId, {
id: Date.now(),
role: 'user',
content: this.inputMsg,
});
this.fetchConversation();
this.fetchMessages();
this.messages = [
...this.messages,
{ role: 'user', content: this.inputMsg },
];
this.inputMsg = '';
this.pendingMsg = { role: 'assistant', content: null };
this.state = 'generating';
this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null };
this.isGenerating = true;
try {
this.abortController = new AbortController();
@ -169,12 +267,19 @@
const addedContent = chunk.data.choices[0].delta.content;
const lastContent = this.pendingMsg.content || '';
if (addedContent) {
this.pendingMsg = { role: 'assistant', content: lastContent + addedContent };
this.pendingMsg = {
id: this.pendingMsg.id,
role: 'assistant',
content: lastContent + addedContent,
};
}
}
this.messages = [...this.messages, this.pendingMsg];
Conversations.appendMsg(currConvId, this.pendingMsg);
this.fetchConversation();
this.fetchMessages();
this.pendingMsg = null;
this.state = 'ready';
this.isGenerating = false;
setTimeout(() => {
document.getElementById('msg-input').focus();
}, 1);
@ -182,11 +287,19 @@
console.error(error);
alert(error);
this.pendingMsg = null;
this.state = 'ready';
this.isGenerating = false;
}
},
// sync state functions
fetchConversation() {
this.conversations = Conversations.getAll();
},
fetchMessages() {
this.messages = Conversations.getOne(this.viewingConvId)?.messages ?? [];
},
},
}).mount('#app')
}).mount('#app');
</script>
</body>