Skip to content

Commit 45a2cd2

Browse files
Add download chat
1 parent bd619aa commit 45a2cd2

15 files changed

+189
-24
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ollama-html-ui
22

3-
![cover](/screenshots/main.png)
3+
![cover](/screenshots/chat.png)
44

55
![Contributions Welcome](https://img.shields.io/badge/Contributions-welcome-blue.svg)
66

@@ -17,6 +17,8 @@ A HTML UI for Ollama.
1717
- New chat
1818
- Edit chat
1919
- Delete chat
20+
- Download chat
21+
- Copy chat
2022
- Search chats
2123
- Clear chats
2224
- View settings
@@ -25,7 +27,7 @@ A HTML UI for Ollama.
2527

2628
## Screenshots
2729

28-
![Main screen](/screenshots/main.png)
30+
![Main screen](/screenshots/chat.png)
2931
![Settings screen](/screenshots/settings.png)
3032
![Search](/screenshots/search.png)
3133

css/ChatHistory.scss

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
padding: 0.5rem;
1616
border-radius: 2px;
1717
border: 1px solid #BBDEFB; /* Light blue border (100) */
18+
19+
.button {
20+
margin-top: 1rem;
21+
display: block;
22+
}
1823
}
1924

2025
/* Styling for user messages */
@@ -33,4 +38,3 @@
3338
//border: 1px solid #FFE0B2; /* Light orange border (100) */
3439
border-radius: 2px;
3540
}
36-

css/CopyButton.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.copy-button {
2+
font-family: Arial, sans-serif; /* Ensures the Unicode character displays correctly */
3+
font-size: 16px; /* Adjust as needed */
4+
padding: 10px;
5+
background-color: #4CAF50;
6+
color: white;
7+
border: none;
8+
border-radius: 5px;
9+
cursor: pointer;
10+
}

css/button.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ button {
1818
background-color: #cfcfcf;
1919
}
2020

21+
.button-small {
22+
padding: 0.25rem 1.0rem;
23+
}
24+
25+
.button-mini {
26+
padding: 0.15rem 0.25rem;
27+
}
28+
2129
/* Primary button */
2230
.button-primary {
2331
background-color: #0052cc;

css/icons.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ i[class^="icon-"] {
77
font-size: 12px;
88
}
99

10+
.icon-download:before {
11+
content: '\21E9\00a0'; /* Unicode character for the download icon followed by a non-breaking space */
12+
}
13+
14+
.icon-copy:before {
15+
content: '\2398\00a0'; /* Unicode character for the copy icon followed by a non-breaking space */
16+
font-size: 20px; /* Adjust the size of the icon */
17+
vertical-align: middle;
18+
}
19+
1020
.icon-selected:before {
1121
content: "\203A";
1222
}

index.html

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@
2222
</div>
2323
<!-- Chat menu -->
2424
<h2 id="chat-title" class="col grw" contenteditable required title="Edit chat title">New chat</h2>
25-
<div class="col">
26-
<button id="edit-chat-button" class="button">
27-
<i class="icon-edit"></i>
28-
</button>
29-
<button id="delete-chat-button" class="button">
30-
<i class="icon-delete"></i>
31-
</button>
25+
<div class="buttons">
26+
<button id="edit-chat-button" class="button col" title="Edit chat">
27+
<i class="icon-edit"></i>
28+
</button>
29+
<button id="delete-chat-button" class="button col" title="Delete chat">
30+
<i class="icon-delete"></i>
31+
</button>
32+
<button data-target="chat-history" class="button download-button col" title="Download chat">
33+
<i class="icon-download"></i>
34+
</button>
3235
</div>
3336
</header>
3437
<div id="chat-area">
@@ -108,6 +111,15 @@ <h4>Available models</h4>
108111
-->
109112
</li>
110113
</template>
114+
<!-- Chat message template -->
115+
<template id="chat-message-template">
116+
<div class="message">
117+
<span class="message-text"></span>
118+
<!--
119+
<button class="copy-button button button-mini"><i class="icon-copy"></i></button>
120+
-->
121+
</div>
122+
</template>
111123
<script type="module" src="/js/script.js"></script>
112124
</body>
113125
</html>

js/App.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Event } from './Event.js'
22
import { Chats } from './models/Chats.js'
33
import { LocalStorage } from './models/LocalStorage.js'
44
import { Sidebar } from './Sidebar.js'
5+
import { CopyButton } from './CopyButton.js'
6+
import { DownloadButton } from './DownloadButton.js'
57
import { ChatArea } from './ChatArea.js'
68

79
// Configuration and DOM elements
@@ -10,6 +12,8 @@ const storage = new LocalStorage()
1012
export class App {
1113
static run () {
1214
const app = new App()
15+
this.downloadButton = new DownloadButton()
16+
this.copyButton = new CopyButton()
1317
return app
1418
}
1519

@@ -124,13 +128,26 @@ Chat: ${this.chats.getCurrentChat()?.id}
124128
}
125129

126130
createMessageDiv = (text, sender) => {
127-
const div = document.createElement('div')
128-
div.classList.add('message', `${sender}-message`)
129-
div.textContent = text
130-
div.initialResponse = true
131-
this.chatHistory.appendChild(div)
131+
// Get the template and its content
132+
const template = document.getElementById('chat-message-template')
133+
const messageClone = template.content.cloneNode(true)
134+
135+
// Find the div and span elements within the cloned template
136+
const messageDiv = messageClone.querySelector('div')
137+
const textSpan = messageClone.querySelector('.message-text')
138+
139+
// Set the class for sender and text content
140+
messageDiv.classList.add(`${sender}-message`)
141+
textSpan.textContent = text
142+
143+
// Set initialResponse property
144+
messageDiv.initialResponse = true
145+
146+
// Append to chatHistory and adjust scroll
147+
this.chatHistory.appendChild(messageDiv)
132148
this.chatHistory.scrollTop = this.chatHistory.scrollHeight
133-
return div
149+
150+
return messageDiv
134151
}
135152

136153
getSpinner = () => {

js/App.test.js

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ class AppTest {
5353
await this.page.locator('#button-save-settings').click()
5454
}
5555

56+
async sendMessage (message) {
57+
await this.page.fill('#message-input', message)
58+
await this.page.click('#send-button')
59+
await expect(this.page.locator('#abort-button')).toBeVisible()
60+
await this.page.waitForSelector('#send-button', { timeout: 60000 })
61+
}
62+
5663
async newChat (title) {
5764
await this.page.click('#new-chat-button')
5865
const newTitle = /Untitled/
@@ -145,7 +152,6 @@ test.describe('Application tests', () => {
145152
await app.selectChat('Happy Hamster')
146153
await app.selectChat('Super Hamster')
147154
await app.selectChat('Smart Hamster')
148-
await app.screenshot('screenshots/main.png')
149155
})
150156

151157
test('Edit chat title', async () => {
@@ -155,16 +161,16 @@ test.describe('Application tests', () => {
155161

156162
test('Show settings', async () => {
157163
await app.showSettings()
164+
await app.screenshot('screenshots/settings.png')
158165
})
159166

160167
test('Update settings', async () => {
161168
await app.updateSettings(url, model)
162-
await app.screenshot('screenshots/settings.png')
163169
})
164170

165171
test('Search chats', async () => {
166172
// Create chats for each country
167-
for (const name of ['Finland', 'Sweden', 'Norway', 'Denmark']) {
173+
for (const name of ['Finland', 'Sweden', 'Canada', 'Norway', 'Denmark']) {
168174
await app.newChat(name)
169175
}
170176

@@ -183,7 +189,7 @@ test.describe('Application tests', () => {
183189

184190
// Search for 'Sweden', and verify its visibility
185191
await app.page.fill('#search-input', '')
186-
await app.page.type('#search-input', 'Sweden')
192+
await app.page.type('#search-input', 'w')
187193
await app.page.waitForTimeout(500) // Small delay to allow UI to update
188194
await app.screenshot('screenshots/search.png')
189195
// Check if 'Sweden' is visible
@@ -210,10 +216,7 @@ test.describe('Application tests', () => {
210216
test('Send message', async () => {
211217
await app.updateSettings(url, model)
212218
await app.editChatTitle('What is 10+10?')
213-
await app.page.fill('#message-input', 'What is 10+10?')
214-
await app.page.click('#send-button')
215-
await expect(app.page.locator('#abort-button')).toBeVisible()
216-
await app.page.waitForSelector('#send-button', { timeout: 60000 })
219+
await app.sendMessage('What is 10+10?')
217220
await app.screenshot('screenshots/chat.png')
218221
})
219222

@@ -226,4 +229,29 @@ test.describe('Application tests', () => {
226229
await expect(app.page.locator('#abort-button')).not.toBeVisible()
227230
await expect(app.page.locator('#send-button')).toBeVisible()
228231
})
232+
233+
/*
234+
TODO:
235+
test('Download chat', async () => {
236+
await app.updateSettings(url, model)
237+
await app.sendMessage('What is 10+10?')
238+
239+
// Set up a listener for the download event
240+
const [ download ] = await Promise.all([
241+
// It's important to set up the listener before triggering the download
242+
app.page.waitForEvent('download'),
243+
// Trigger the download here
244+
app.page.click('.download-button')
245+
])
246+
247+
// Wait for the download to complete
248+
const path = await download.path()
249+
250+
// Verify the download (e.g., check file name, size, etc.)
251+
console.log(`Downloaded file: ${path}`)
252+
253+
// Optional: Check the download's filename
254+
console.log(`Downloaded filename: ${download.suggestedFilename()}`)
255+
})
256+
*/
229257
})

js/CopyButton.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export class CopyButton {
2+
static run () {
3+
const btn = new CopyButton()
4+
return btn
5+
}
6+
7+
constructor () {
8+
document.addEventListener('click', function (event) {
9+
// Check if the clicked element has the class 'copy-button'
10+
if (event.target.classList.contains('copy-button')) {
11+
// Get the index from the data-target attribute
12+
const targetIndex = this.getAttribute('data-target')
13+
14+
// Select the corresponding text element
15+
const textToCopy = document.querySelectorAll('.copyText')[targetIndex].innerText
16+
17+
// Create a temporary textarea element
18+
const textarea = document.createElement('textarea')
19+
textarea.value = textToCopy
20+
document.body.appendChild(textarea)
21+
22+
// Select the text and copy it
23+
textarea.select()
24+
document.execCommand('copy')
25+
26+
// Remove the temporary textarea
27+
document.body.removeChild(textarea)
28+
29+
// Optional: Display a message or change the button text/content
30+
alert('Text copied to clipboard')
31+
}
32+
})
33+
}
34+
}

js/DownloadButton.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export class DownloadButton {
2+
constructor () {
3+
document.addEventListener('click', (event) => {
4+
// Check if the clicked element has the class 'copy-button'
5+
if (event.target.classList.contains('download-button')) {
6+
// Get the index from the data-target attribute
7+
const targetId = event.target.getAttribute('data-target')
8+
this.downloadElementContent(targetId, 'chat.html')
9+
}
10+
})
11+
}
12+
13+
downloadElementContent (elementId, filename) {
14+
// Get the element
15+
const element = document.getElementById(elementId)
16+
if (!element) {
17+
console.error('Element not found')
18+
return
19+
}
20+
21+
// Get the contents of the element
22+
const content = element.innerText
23+
24+
// Create a Blob with the content
25+
const blob = new Blob([content], { type: 'text/html' })
26+
27+
// Create an anchor element and set the href to the blob
28+
const a = document.createElement('a')
29+
a.href = URL.createObjectURL(blob)
30+
a.download = filename
31+
32+
// Append the anchor to the document, trigger a click, and then remove it
33+
document.body.appendChild(a)
34+
a.click()
35+
document.body.removeChild(a)
36+
37+
// Revoke the blob URL
38+
URL.revokeObjectURL(a.href)
39+
}
40+
}

0 commit comments

Comments
 (0)