Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

History export #69

Merged
merged 4 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}