add conversation history, save to localStorage
This commit is contained in:
parent
7f3daf09f3
commit
9719450232
3 changed files with 172 additions and 61 deletions
|
@ -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
|
# 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
|
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
|
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
|
@ -7,29 +7,55 @@
|
||||||
|
|
||||||
<!-- Note: dependencies can de updated using ./deps.sh script -->
|
<!-- Note: dependencies can de updated using ./deps.sh script -->
|
||||||
<link href="./deps_daisyui.min.css" rel="stylesheet" type="text/css" />
|
<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 src="./deps_tailwindcss.js"></script>
|
||||||
<script>
|
|
||||||
tailwind.config = {};
|
|
||||||
</script>
|
|
||||||
<style type="text/tailwindcss">
|
<style type="text/tailwindcss">
|
||||||
.markdown {
|
.markdown {
|
||||||
h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
|
h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
|
||||||
pre { @apply whitespace-pre-wrap; }
|
pre { @apply whitespace-pre-wrap; }
|
||||||
/* TODO: fix table */
|
/* TODO: fix markdown table */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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">
|
<div class="flex flex-col w-screen h-screen max-w-screen-md px-8 mx-auto">
|
||||||
<!-- header -->
|
<!-- header -->
|
||||||
<div class="flex flex-row items-center">
|
<div class="flex flex-row items-center">
|
||||||
<div class="grow text-2xl font-bold mt-8 mb-6">
|
<div class="grow text-2xl font-bold mt-8 mb-6">
|
||||||
🦙 llama.cpp - chat
|
🦙 llama.cpp - chat
|
||||||
</div>
|
</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/ -->
|
<!-- 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 class="dropdown dropdown-end dropdown-bottom">
|
||||||
<div tabindex="0" role="button" class="btn m-1">
|
<div tabindex="0" role="button" class="btn m-1">
|
||||||
Theme
|
Theme
|
||||||
|
@ -42,7 +68,7 @@
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="theme-dropdown"
|
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"
|
:aria-label="theme"
|
||||||
:value="theme" />
|
:value="theme" />
|
||||||
</li>
|
</li>
|
||||||
|
@ -58,7 +84,7 @@
|
||||||
{{ messages.length === 0 ? 'Send a message to start' : '' }}
|
{{ messages.length === 0 ? 'Send a message to start' : '' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-for="msg in messages" :class="{
|
<div v-for="msg in messages" :class="{
|
||||||
'chat': true,
|
'chat group': true,
|
||||||
'chat-start': msg.role !== 'user',
|
'chat-start': msg.role !== 'user',
|
||||||
'chat-end': msg.role === 'user',
|
'chat-end': msg.role === 'user',
|
||||||
}">
|
}">
|
||||||
|
@ -68,6 +94,9 @@
|
||||||
}">
|
}">
|
||||||
<vue-markdown :source="msg.content" />
|
<vue-markdown :source="msg.content" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="msg.role === 'user'" class="badge cursor-pointer opacity-0 group-hover:opacity-100">
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- pending assistant message -->
|
<!-- pending assistant message -->
|
||||||
|
@ -86,10 +115,10 @@
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
v-model="inputMsg"
|
v-model="inputMsg"
|
||||||
@keydown.enter="sendMessage"
|
@keydown.enter="sendMessage"
|
||||||
v-bind:disabled="state !== 'ready'"
|
v-bind:disabled="isGenerating"
|
||||||
id="msg-input"
|
id="msg-input"
|
||||||
></textarea>
|
></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -99,7 +128,8 @@
|
||||||
import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
|
import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
|
||||||
import { llama } from './completion.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
|
// markdown support
|
||||||
const VueMarkdown = defineComponent(
|
const VueMarkdown = defineComponent(
|
||||||
|
@ -114,43 +144,111 @@
|
||||||
{ props: ["source", "options", "plugins"] }
|
{ 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({
|
createApp({
|
||||||
components: {
|
components: {
|
||||||
VueMarkdown,
|
VueMarkdown,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
conversations: Conversations.getAll(),
|
||||||
messages: [],
|
messages: [],
|
||||||
|
viewingConvId: Conversations.getNewConvId(),
|
||||||
inputMsg: '',
|
inputMsg: '',
|
||||||
state: 'ready',
|
isGenerating: false,
|
||||||
pendingMsg: null, // the on-going message from assistant
|
pendingMsg: null, // the on-going message from assistant
|
||||||
abortController: null,
|
abortController: null,
|
||||||
// const
|
// const
|
||||||
themes: ['light', 'dark', 'retro', 'cyberpunk', 'aqua', 'valentine', 'synthwave'],
|
themes: ['light', 'dark', 'retro', 'cyberpunk', 'aqua', 'valentine', 'synthwave'],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {},
|
||||||
mounted() {
|
mounted() {
|
||||||
// scroll to the bottom when the pending message height is updated
|
// scroll to the bottom when the pending message height is updated
|
||||||
const pendingMsgElem = document.getElementById('pending-msg');
|
const pendingMsgElem = document.getElementById('pending-msg');
|
||||||
const msgListElem = document.getElementById('messages-list');
|
const msgListElem = document.getElementById('messages-list');
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (this.state === 'generating') {
|
if (this.isGenerating) {
|
||||||
msgListElem.scrollTo({ top: msgListElem.scrollHeight });
|
msgListElem.scrollTo({ top: msgListElem.scrollHeight });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
resizeObserver.observe(pendingMsgElem);
|
resizeObserver.observe(pendingMsgElem);
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
async sendMessage() {
|
||||||
if (!this.inputMsg) return;
|
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.inputMsg = '';
|
||||||
this.pendingMsg = { role: 'assistant', content: null };
|
this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null };
|
||||||
this.state = 'generating';
|
this.isGenerating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
|
@ -169,12 +267,19 @@
|
||||||
const addedContent = chunk.data.choices[0].delta.content;
|
const addedContent = chunk.data.choices[0].delta.content;
|
||||||
const lastContent = this.pendingMsg.content || '';
|
const lastContent = this.pendingMsg.content || '';
|
||||||
if (addedContent) {
|
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.pendingMsg = null;
|
||||||
this.state = 'ready';
|
this.isGenerating = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('msg-input').focus();
|
document.getElementById('msg-input').focus();
|
||||||
}, 1);
|
}, 1);
|
||||||
|
@ -182,11 +287,19 @@
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert(error);
|
alert(error);
|
||||||
this.pendingMsg = null;
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue