Skip to content

Commit

Permalink
History export (#69)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
cbh778899 authored Oct 21, 2024
1 parent 7c12b86 commit cee3213
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 8 deletions.
16 changes: 15 additions & 1 deletion electron.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// eslint-disable-next-line
const { app, Menu, BrowserWindow, ipcMain } = require('electron');
const { app, Menu, BrowserWindow, ipcMain, dialog } = require('electron');
// eslint-disable-next-line
const path = require('path');

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

ipcMain.handle('electron-settings', ()=>{
return { userDataPath: app.getPath("userData"), isPackaged: app.isPackaged }
})

ipcMain.handle('os-settings', ()=>{
return {
downloads_path: app.getPath("downloads")
}
})

ipcMain.handle('show-save-dialog', async (event, ...args) => {
return await dialog.showSaveDialog(...args);
})

ipcMain.handle('show-open-dialog', async (event, ...args) => {
return await dialog.showOpenDialog(...args);
})
44 changes: 44 additions & 0 deletions preloader/file-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const { ipcRenderer } = require('electron');
const { join } = require("path")
const { writeFileSync, readFileSync } = require("fs")

let save_path = '.';

async function initer() {
const {downloads_path} = await ipcRenderer.invoke('os-settings');
save_path = downloads_path;
}
initer();

async function saveFile(name, content) {
const { canceled, filePath } = await ipcRenderer.invoke('show-save-dialog', {
title: "Save Chat History",
defaultPath: join(save_path, name)
})

if(canceled || !filePath) {
return false;
} else {
writeFileSync(filePath, content, 'utf-8');
return true;
}
}

async function loadFile() {
const { canceled, filePaths } = await ipcRenderer.invoke('show-open-dialog', {
title: "Load Chat History",
properties: ["openFile"]
})

if(canceled || !filePaths) {
return null;
} else {
const content = readFileSync(filePaths[0], 'utf-8');
return content;
}
}

module.exports = {
saveFile,
loadFile
}
8 changes: 8 additions & 0 deletions preloader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ const {
updateModelSettings
} = require("./node-llama-cpp-preloader.js")

const {
loadFile, saveFile
} = require("./file-handler.js")

contextBridge.exposeInMainWorld('node-llama-cpp', {
loadModel, chatCompletions, updateModelSettings,
abortCompletion, setClient, downloadModel
})

contextBridge.exposeInMainWorld('file-handler', {
loadFile, saveFile
})
3 changes: 2 additions & 1 deletion src/components/chat/ChatPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import UserMessage from "./UserMessage";
export default function ChatPage({
chat, chat_history, updateTitle,
sendMessage, pending_message, abort,
updateSystemInstruction
updateSystemInstruction, saveHistory
}) {

return (
Expand All @@ -15,6 +15,7 @@ export default function ChatPage({
current_title={chat.title} updateTitle={updateTitle}
updateSystemInstruction={updateSystemInstruction}
current_instruction={chat['system-instruction']}
saveHistory={saveHistory}
/>
<Bubbles conversation={chat_history} pending_message={pending_message} />
<UserMessage
Expand Down
39 changes: 34 additions & 5 deletions src/components/chat/TitleBar.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { useEffect, useRef, useState } from "react";
import { ChatRightText, PencilFill, Save } from "react-bootstrap-icons";
import { XCircle } from "react-bootstrap-icons";
import { CheckCircle } from "react-bootstrap-icons";
import {
ChatRightText, PencilFill,
Save, ChatSquareText, Braces,
XCircle, CheckCircle
} from "react-bootstrap-icons";

export default function TitleBar({current_title, updateTitle, current_instruction, updateSystemInstruction}) {
export default function TitleBar({
current_title, updateTitle,
current_instruction, updateSystemInstruction,
saveHistory
}) {
const [title, setTitle] = useState(current_title);
const [is_editing, toggleEditTitle] = useState(false);
const [system_instruction, setSystemInstruction] = useState(current_instruction || '');
const [is_editing_si, toggleEditSI] = useState(false);

const inputRef = useRef(null);
const systemInstructionDialogRef = useRef();
const exportFormatDialogRef = useRef();

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

async function submitSaveHistory(format) {
await saveHistory(format);
exportFormatDialogRef.current.close();
}

useEffect(()=>{
setSystemInstruction(current_instruction);
}, [current_instruction])
Expand Down Expand Up @@ -65,7 +77,7 @@ export default function TitleBar({current_title, updateTitle, current_instructio
<PencilFill className="edit-icon" />
</div>
<ChatRightText className="icon clickable" title="Set the system instruction" onClick={()=>toggleEditSI(true)} />
<Save className="icon clickable" title="Save history" />
<Save className="icon clickable" title="Save history" onClick={()=>exportFormatDialogRef.current.showModal()} />
</div>
}
<dialog className="system-instruction" ref={systemInstructionDialogRef} onClose={()=>toggleEditSI(false)}>
Expand All @@ -78,6 +90,23 @@ export default function TitleBar({current_title, updateTitle, current_instructio
<div className="btn clickable" onClick={()=>toggleEditSI(false)}>Cancel</div>
</form>
</dialog>
<dialog
className="export-format"
onClick={evt=>evt.target.close()}
ref={exportFormatDialogRef}
>
<div className="export-format-main" onClick={evt=>evt.stopPropagation()}>
<div className="title">Please select a format to export</div>
<div className="export-btn clickable" onClick={()=>submitSaveHistory("JSON")}>
<Braces className="icon" />
<div className="text">Export as JSON</div>
</div>
<div className="export-btn clickable" onClick={()=>submitSaveHistory("Human")}>
<ChatSquareText className="icon" />
<div className="text">Export as Plain Text</div>
</div>
</div>
</dialog>
</div>
)
}
9 changes: 8 additions & 1 deletion src/components/chat/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import DeleteConfirm from "./DeleteConfirm";
import ChatPage from "./ChatPage";
import { getCompletionFunctions } from "../../utils/workers";
import { getPlatformSettings } from "../../utils/general_settings";
import { exportChatHistory } from "../../utils/chat-history-handler";

export default function Chat() {

Expand Down Expand Up @@ -151,6 +152,12 @@ export default function Chat() {
})
}

async function saveHistory(format) {
if(!chat.uid) return;
const full_history = await idb.getAll('messages', {where: [{'history-uid': chat.uid}], select: ['role', 'content', 'createdAt']})
await exportChatHistory(format, chat, full_history);
}

useEffect(()=>{
conv_to_delete && toggleConfirm(true);
}, [conv_to_delete])
Expand All @@ -172,7 +179,7 @@ export default function Chat() {
deleteHistory={requestDelete} platform={platform.current}
/>
<ChatPage
updateTitle={updateTitle}
updateTitle={updateTitle} saveHistory={saveHistory}
chat={chat} chat_history={chat_history}
pending_message={pending_message} abort={session_setting.abort}
sendMessage={sendMessage} updateSystemInstruction={updateSystemInstruction}
Expand Down
40 changes: 40 additions & 0 deletions src/styles/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,43 @@ input[type="text"] {
.button-container > .button-icon.highlight {
color: dodgerblue;
}

dialog.export-format {
background-color: transparent;
border-radius: 10px;
padding: 0;
border: unset;
}

dialog.export-format > .export-format-main {
background-color: white;
border-radius: 10px;
padding: 20px 30px;
}

dialog.export-format > .export-format-main > .title {
font-size: 18px;
margin-bottom: 20px;
}

dialog.export-format > .export-format-main > .export-btn {
display: flex;
align-items: center;
height: 40px;
border: 2px solid lightgray;
margin-bottom: 15px;
border-radius: 10px;
transition-duration: .3s;
}
dialog.export-format > .export-format-main > .export-btn:hover {
border-color: gray;
}

dialog.export-format > .export-format-main > .export-btn > .icon {
margin-left: auto;
margin-right: 10px;
}

dialog.export-format > .export-format-main > .export-btn > .text {
margin-right: auto;
}
97 changes: 97 additions & 0 deletions src/utils/chat-history-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @typedef HistoryItem
* @property {"user"|"assistant"|"system"} role
* @property {String} content
* @property {Number} createdAt
*/

/**
* @typedef HistoryInfo
* @property {Number} createdAt
* @property {String} title
*/

/**
* Export history by generate a file
* @param {"Human"|"JSON"} format the format to export, either human readable or json
* @param {HistoryInfo} history_info the information of the session, includes create time and title
* @param {HistoryItem[]} history the history to be exported
*/
export async function exportChatHistory(format, history_info, history) {
let formatted_sentence;
switch(format) {
case "JSON":
formatted_sentence = jsonFormator(history_info, history);
break;
case "Human":
formatted_sentence = humanFormator(history_info, history);
break;
}
// const file = new Blob([formatted_sentence], { type: "text/plain" });

const extension =
format === "JSON" ? 'json' :
format === "Human" ? 'txt' :
''

await window['file-handler'].saveFile(`${history_info.title}.${extension}`, formatted_sentence);
}

function dateFormator(timestamp) {
const dt = new Date(timestamp);
return `${dt.toDateString()}, ${dt.toTimeString().match(/(\d{2}:\d{2}:\d{2})/)[0]}`
}

/**
* Format history to human readable format and return the formatted string
* @param {HistoryInfo} history_info the information of the session to be formatted
* @param {HistoryItem[]} history the history to be formatted
* @returns {String}
*/
function humanFormator(history_info, history) {
const parts = [
`${history_info.title}
${dateFormator(history_info.createdAt)}`
]

let current_part = '';
for(const { role, content, createdAt } of history) {
current_part +=
role === "user" ? 'user ' :
role === "system" ? 'system ':
role === 'assistant' ? 'assistant' : ''
current_part += ` (${dateFormator(createdAt)}): `
current_part += content

if(/^(system|user)$/.test(role)) {
current_part += '\n';
} else {
parts.push(current_part);
current_part = ''
}
}

return parts.join('\n\n---\n\n')
}

/**
* Format history to json format and return the formatted string
* @param {HistoryInfo} history_info the information of the session to be formatted
* @param {HistoryItem[]} history the history to be formatted
* @returns {String}
*/
function jsonFormator(history_info, history) {
const { title, createdAt } = history_info;
const formatted =
`{
"title": "${title}",
"createdAt": ${createdAt},
"messages": [
${history.map(({role, content, createdAt})=>{
const msg_str = content.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll("\n", "\\n")
return `{ "role": "${role}", "message": "${msg_str}", "createdAt": ${createdAt} }`
}).join(`,\n${" ".repeat(8)}`)}
]
}`
return formatted
}

0 comments on commit cee3213

Please sign in to comment.