Skip to content

Commit cee3213

Browse files
authored
History export (#69)
* add function to save file Signed-off-by: cbh778899 <[email protected]> * add handler to format chat history for export Signed-off-by: cbh778899 <[email protected]> * implement export history Signed-off-by: cbh778899 <[email protected]> * update style Signed-off-by: cbh778899 <[email protected]> --------- Signed-off-by: cbh778899 <[email protected]>
1 parent 7c12b86 commit cee3213

File tree

8 files changed

+248
-8
lines changed

8 files changed

+248
-8
lines changed

electron.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// eslint-disable-next-line
2-
const { app, Menu, BrowserWindow, ipcMain } = require('electron');
2+
const { app, Menu, BrowserWindow, ipcMain, dialog } = require('electron');
33
// eslint-disable-next-line
44
const path = require('path');
55

@@ -49,4 +49,18 @@ app.whenReady().then(() => {
4949

5050
ipcMain.handle('electron-settings', ()=>{
5151
return { userDataPath: app.getPath("userData"), isPackaged: app.isPackaged }
52+
})
53+
54+
ipcMain.handle('os-settings', ()=>{
55+
return {
56+
downloads_path: app.getPath("downloads")
57+
}
58+
})
59+
60+
ipcMain.handle('show-save-dialog', async (event, ...args) => {
61+
return await dialog.showSaveDialog(...args);
62+
})
63+
64+
ipcMain.handle('show-open-dialog', async (event, ...args) => {
65+
return await dialog.showOpenDialog(...args);
5266
})

preloader/file-handler.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const { ipcRenderer } = require('electron');
2+
const { join } = require("path")
3+
const { writeFileSync, readFileSync } = require("fs")
4+
5+
let save_path = '.';
6+
7+
async function initer() {
8+
const {downloads_path} = await ipcRenderer.invoke('os-settings');
9+
save_path = downloads_path;
10+
}
11+
initer();
12+
13+
async function saveFile(name, content) {
14+
const { canceled, filePath } = await ipcRenderer.invoke('show-save-dialog', {
15+
title: "Save Chat History",
16+
defaultPath: join(save_path, name)
17+
})
18+
19+
if(canceled || !filePath) {
20+
return false;
21+
} else {
22+
writeFileSync(filePath, content, 'utf-8');
23+
return true;
24+
}
25+
}
26+
27+
async function loadFile() {
28+
const { canceled, filePaths } = await ipcRenderer.invoke('show-open-dialog', {
29+
title: "Load Chat History",
30+
properties: ["openFile"]
31+
})
32+
33+
if(canceled || !filePaths) {
34+
return null;
35+
} else {
36+
const content = readFileSync(filePaths[0], 'utf-8');
37+
return content;
38+
}
39+
}
40+
41+
module.exports = {
42+
saveFile,
43+
loadFile
44+
}

preloader/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ const {
88
updateModelSettings
99
} = require("./node-llama-cpp-preloader.js")
1010

11+
const {
12+
loadFile, saveFile
13+
} = require("./file-handler.js")
14+
1115
contextBridge.exposeInMainWorld('node-llama-cpp', {
1216
loadModel, chatCompletions, updateModelSettings,
1317
abortCompletion, setClient, downloadModel
18+
})
19+
20+
contextBridge.exposeInMainWorld('file-handler', {
21+
loadFile, saveFile
1422
})

src/components/chat/ChatPage.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import UserMessage from "./UserMessage";
55
export default function ChatPage({
66
chat, chat_history, updateTitle,
77
sendMessage, pending_message, abort,
8-
updateSystemInstruction
8+
updateSystemInstruction, saveHistory
99
}) {
1010

1111
return (
@@ -15,6 +15,7 @@ export default function ChatPage({
1515
current_title={chat.title} updateTitle={updateTitle}
1616
updateSystemInstruction={updateSystemInstruction}
1717
current_instruction={chat['system-instruction']}
18+
saveHistory={saveHistory}
1819
/>
1920
<Bubbles conversation={chat_history} pending_message={pending_message} />
2021
<UserMessage

src/components/chat/TitleBar.jsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { useEffect, useRef, useState } from "react";
2-
import { ChatRightText, PencilFill, Save } from "react-bootstrap-icons";
3-
import { XCircle } from "react-bootstrap-icons";
4-
import { CheckCircle } from "react-bootstrap-icons";
2+
import {
3+
ChatRightText, PencilFill,
4+
Save, ChatSquareText, Braces,
5+
XCircle, CheckCircle
6+
} from "react-bootstrap-icons";
57

6-
export default function TitleBar({current_title, updateTitle, current_instruction, updateSystemInstruction}) {
8+
export default function TitleBar({
9+
current_title, updateTitle,
10+
current_instruction, updateSystemInstruction,
11+
saveHistory
12+
}) {
713
const [title, setTitle] = useState(current_title);
814
const [is_editing, toggleEditTitle] = useState(false);
915
const [system_instruction, setSystemInstruction] = useState(current_instruction || '');
1016
const [is_editing_si, toggleEditSI] = useState(false);
1117

1218
const inputRef = useRef(null);
1319
const systemInstructionDialogRef = useRef();
20+
const exportFormatDialogRef = useRef();
1421

1522
function submitUpdateTitle() {
1623
if(is_editing && title !== current_title) {
@@ -27,6 +34,11 @@ export default function TitleBar({current_title, updateTitle, current_instructio
2734
toggleEditSI(false)
2835
}
2936

37+
async function submitSaveHistory(format) {
38+
await saveHistory(format);
39+
exportFormatDialogRef.current.close();
40+
}
41+
3042
useEffect(()=>{
3143
setSystemInstruction(current_instruction);
3244
}, [current_instruction])
@@ -65,7 +77,7 @@ export default function TitleBar({current_title, updateTitle, current_instructio
6577
<PencilFill className="edit-icon" />
6678
</div>
6779
<ChatRightText className="icon clickable" title="Set the system instruction" onClick={()=>toggleEditSI(true)} />
68-
<Save className="icon clickable" title="Save history" />
80+
<Save className="icon clickable" title="Save history" onClick={()=>exportFormatDialogRef.current.showModal()} />
6981
</div>
7082
}
7183
<dialog className="system-instruction" ref={systemInstructionDialogRef} onClose={()=>toggleEditSI(false)}>
@@ -78,6 +90,23 @@ export default function TitleBar({current_title, updateTitle, current_instructio
7890
<div className="btn clickable" onClick={()=>toggleEditSI(false)}>Cancel</div>
7991
</form>
8092
</dialog>
93+
<dialog
94+
className="export-format"
95+
onClick={evt=>evt.target.close()}
96+
ref={exportFormatDialogRef}
97+
>
98+
<div className="export-format-main" onClick={evt=>evt.stopPropagation()}>
99+
<div className="title">Please select a format to export</div>
100+
<div className="export-btn clickable" onClick={()=>submitSaveHistory("JSON")}>
101+
<Braces className="icon" />
102+
<div className="text">Export as JSON</div>
103+
</div>
104+
<div className="export-btn clickable" onClick={()=>submitSaveHistory("Human")}>
105+
<ChatSquareText className="icon" />
106+
<div className="text">Export as Plain Text</div>
107+
</div>
108+
</div>
109+
</dialog>
81110
</div>
82111
)
83112
}

src/components/chat/index.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DeleteConfirm from "./DeleteConfirm";
66
import ChatPage from "./ChatPage";
77
import { getCompletionFunctions } from "../../utils/workers";
88
import { getPlatformSettings } from "../../utils/general_settings";
9+
import { exportChatHistory } from "../../utils/chat-history-handler";
910

1011
export default function Chat() {
1112

@@ -151,6 +152,12 @@ export default function Chat() {
151152
})
152153
}
153154

155+
async function saveHistory(format) {
156+
if(!chat.uid) return;
157+
const full_history = await idb.getAll('messages', {where: [{'history-uid': chat.uid}], select: ['role', 'content', 'createdAt']})
158+
await exportChatHistory(format, chat, full_history);
159+
}
160+
154161
useEffect(()=>{
155162
conv_to_delete && toggleConfirm(true);
156163
}, [conv_to_delete])
@@ -172,7 +179,7 @@ export default function Chat() {
172179
deleteHistory={requestDelete} platform={platform.current}
173180
/>
174181
<ChatPage
175-
updateTitle={updateTitle}
182+
updateTitle={updateTitle} saveHistory={saveHistory}
176183
chat={chat} chat_history={chat_history}
177184
pending_message={pending_message} abort={session_setting.abort}
178185
sendMessage={sendMessage} updateSystemInstruction={updateSystemInstruction}

src/styles/chat.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,43 @@ input[type="text"] {
417417
.button-container > .button-icon.highlight {
418418
color: dodgerblue;
419419
}
420+
421+
dialog.export-format {
422+
background-color: transparent;
423+
border-radius: 10px;
424+
padding: 0;
425+
border: unset;
426+
}
427+
428+
dialog.export-format > .export-format-main {
429+
background-color: white;
430+
border-radius: 10px;
431+
padding: 20px 30px;
432+
}
433+
434+
dialog.export-format > .export-format-main > .title {
435+
font-size: 18px;
436+
margin-bottom: 20px;
437+
}
438+
439+
dialog.export-format > .export-format-main > .export-btn {
440+
display: flex;
441+
align-items: center;
442+
height: 40px;
443+
border: 2px solid lightgray;
444+
margin-bottom: 15px;
445+
border-radius: 10px;
446+
transition-duration: .3s;
447+
}
448+
dialog.export-format > .export-format-main > .export-btn:hover {
449+
border-color: gray;
450+
}
451+
452+
dialog.export-format > .export-format-main > .export-btn > .icon {
453+
margin-left: auto;
454+
margin-right: 10px;
455+
}
456+
457+
dialog.export-format > .export-format-main > .export-btn > .text {
458+
margin-right: auto;
459+
}

src/utils/chat-history-handler.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @typedef HistoryItem
3+
* @property {"user"|"assistant"|"system"} role
4+
* @property {String} content
5+
* @property {Number} createdAt
6+
*/
7+
8+
/**
9+
* @typedef HistoryInfo
10+
* @property {Number} createdAt
11+
* @property {String} title
12+
*/
13+
14+
/**
15+
* Export history by generate a file
16+
* @param {"Human"|"JSON"} format the format to export, either human readable or json
17+
* @param {HistoryInfo} history_info the information of the session, includes create time and title
18+
* @param {HistoryItem[]} history the history to be exported
19+
*/
20+
export async function exportChatHistory(format, history_info, history) {
21+
let formatted_sentence;
22+
switch(format) {
23+
case "JSON":
24+
formatted_sentence = jsonFormator(history_info, history);
25+
break;
26+
case "Human":
27+
formatted_sentence = humanFormator(history_info, history);
28+
break;
29+
}
30+
// const file = new Blob([formatted_sentence], { type: "text/plain" });
31+
32+
const extension =
33+
format === "JSON" ? 'json' :
34+
format === "Human" ? 'txt' :
35+
''
36+
37+
await window['file-handler'].saveFile(`${history_info.title}.${extension}`, formatted_sentence);
38+
}
39+
40+
function dateFormator(timestamp) {
41+
const dt = new Date(timestamp);
42+
return `${dt.toDateString()}, ${dt.toTimeString().match(/(\d{2}:\d{2}:\d{2})/)[0]}`
43+
}
44+
45+
/**
46+
* Format history to human readable format and return the formatted string
47+
* @param {HistoryInfo} history_info the information of the session to be formatted
48+
* @param {HistoryItem[]} history the history to be formatted
49+
* @returns {String}
50+
*/
51+
function humanFormator(history_info, history) {
52+
const parts = [
53+
`${history_info.title}
54+
${dateFormator(history_info.createdAt)}`
55+
]
56+
57+
let current_part = '';
58+
for(const { role, content, createdAt } of history) {
59+
current_part +=
60+
role === "user" ? 'user ' :
61+
role === "system" ? 'system ':
62+
role === 'assistant' ? 'assistant' : ''
63+
current_part += ` (${dateFormator(createdAt)}): `
64+
current_part += content
65+
66+
if(/^(system|user)$/.test(role)) {
67+
current_part += '\n';
68+
} else {
69+
parts.push(current_part);
70+
current_part = ''
71+
}
72+
}
73+
74+
return parts.join('\n\n---\n\n')
75+
}
76+
77+
/**
78+
* Format history to json format and return the formatted string
79+
* @param {HistoryInfo} history_info the information of the session to be formatted
80+
* @param {HistoryItem[]} history the history to be formatted
81+
* @returns {String}
82+
*/
83+
function jsonFormator(history_info, history) {
84+
const { title, createdAt } = history_info;
85+
const formatted =
86+
`{
87+
"title": "${title}",
88+
"createdAt": ${createdAt},
89+
"messages": [
90+
${history.map(({role, content, createdAt})=>{
91+
const msg_str = content.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll("\n", "\\n")
92+
return `{ "role": "${role}", "message": "${msg_str}", "createdAt": ${createdAt} }`
93+
}).join(`,\n${" ".repeat(8)}`)}
94+
]
95+
}`
96+
return formatted
97+
}

0 commit comments

Comments
 (0)