Webui: New Features for Conversations, Settings, and Chat Messages (#618)

* Webui: add Rename/Upload conversation in header and sidebar

webui: don't change modified date when renaming conversation

* webui: add a preset feature to the settings #14649

* webui: Add editing assistant messages #13522

Webui: keep the following message while editing assistance response.

webui: change icon to edit message

* webui: DB import and export #14347

* webui: Wrap long numbers instead of infinite horizontal scroll (#14062)
fix sidebar being covered by main content #14082

---------

Co-authored-by: firecoperana <firecoperana>
This commit is contained in:
firecoperana
2025-07-20 05:33:55 -05:00
committed by GitHub
parent e1164e1fd8
commit 18eeb48941
17 changed files with 1560 additions and 163 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<meta name="color-scheme" content="light dark" />
<title>🦙 llama.cpp - chat</title>
<title>🦙 ik_llama.cpp - chat</title>
</head>
<body>
<div id="root"></div>

View File

@@ -16,11 +16,13 @@
"autoprefixer": "^10.4.20",
"daisyui": "^5.0.12",
"dexie": "^4.0.11",
"dexie-export-import": "^4.0.11",
"highlight.js": "^11.10.0",
"katex": "^0.16.15",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.5.2",
"react-markdown": "^9.0.3",
"react-router": "^7.1.5",
"rehype-highlight": "^7.0.2",
@@ -2444,6 +2446,15 @@
"integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==",
"license": "Apache-2.0"
},
"node_modules/dexie-export-import": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/dexie-export-import/-/dexie-export-import-4.1.4.tgz",
"integrity": "sha512-3bw171qUuOTWSYLXI7C/0M6p1X65Rho3tu1IvD9By8jn0+3t3dLSkDlZ1BC6MbABl3kRlhtGigzC+SF+qcS5Og==",
"license": "Apache-2.0",
"peerDependencies": {
"dexie": "^2.0.4 || ^3.0.0 || ^4.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.91",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.91.tgz",
@@ -2917,6 +2928,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -4938,6 +4958,23 @@
"react": "^18.3.1"
}
},
"node_modules/react-hot-toast": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
"integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-markdown": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz",

View File

@@ -19,11 +19,13 @@
"autoprefixer": "^10.4.20",
"daisyui": "^5.0.12",
"dexie": "^4.0.11",
"dexie-export-import": "^4.0.11",
"highlight.js": "^11.10.0",
"katex": "^0.16.15",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.5.2",
"react-markdown": "^9.0.3",
"react-router": "^7.1.5",
"rehype-highlight": "^7.0.2",

View File

@@ -4,21 +4,24 @@ import Sidebar from './components/Sidebar';
import { AppContextProvider, useAppContext } from './utils/app.context';
import ChatScreen from './components/ChatScreen';
import SettingDialog from './components/SettingDialog';
import { ModalProvider } from './components/ModalProvider';
function App() {
return (
<HashRouter>
<div className="flex flex-row drawer lg:drawer-open">
<AppContextProvider>
<Routes>
<Route element={<AppLayout />}>
<Route path="/chat/:convId" element={<ChatScreen />} />
<Route path="*" element={<ChatScreen />} />
</Route>
</Routes>
</AppContextProvider>
</div>
</HashRouter>
<ModalProvider>
<HashRouter>
<div className="flex flex-row drawer lg:drawer-open">
<AppContextProvider>
<Routes>
<Route element={<AppLayout />}>
<Route path="/chat/:convId" element={<ChatScreen />} />
<Route path="*" element={<ChatScreen />} />
</Route>
</Routes>
</AppContextProvider>
</div>
</HashRouter>
</ModalProvider>
);
}
@@ -28,7 +31,7 @@ function AppLayout() {
<>
<Sidebar />
<div
className="drawer-content grow flex flex-col h-screen w-screen mx-auto px-4 overflow-auto bg-base-100"
className="drawer-content grow flex flex-col h-screen mx-auto px-4 overflow-auto bg-base-100"
id="main-scroll"
>
<Header />

View File

@@ -20,6 +20,7 @@ export default function ChatMessage({
onEditMessage,
onChangeSibling,
isPending,
onContinueMessage,
}: {
msg: Message | PendingMessage;
siblingLeafNodeIds: Message['id'][];
@@ -27,6 +28,7 @@ export default function ChatMessage({
id?: string;
onRegenerateMessage(msg: Message): void;
onEditMessage(msg: Message, content: string): void;
onContinueMessage(msg: Message, content: string): void;
onChangeSibling(sibling: Message['id']): void;
isPending?: boolean;
}) {
@@ -112,7 +114,11 @@ export default function ChatMessage({
onClick={() => {
if (msg.content !== null) {
setEditingContent(null);
onEditMessage(msg as Message, editingContent);
if (msg.role === 'user') {
onEditMessage(msg as Message, editingContent);
} else {
onContinueMessage(msg as Message, editingContent);
}
}
}}
>
@@ -283,6 +289,15 @@ export default function ChatMessage({
🔄 Regenerate
</button>
)}
{!isPending && (
<button
className="badge btn-mini show-on-hover"
onClick={() => setEditingContent(msg.content)}
disabled={msg.content === null}
>
Edit
</button>
)}
</>
)}
<CopyButton

View File

@@ -99,6 +99,7 @@ export default function ChatScreen() {
pendingMessages,
canvasData,
replaceMessageAndGenerate,
continueMessageAndGenerate,
} = useAppContext();
const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content());
@@ -187,6 +188,20 @@ export default function ChatScreen() {
scrollToBottom(false);
};
const handleContinueMessage = async (msg: Message, content: string) => {
if (!viewingChat || !continueMessageAndGenerate) return;
setCurrNodeId(msg.id);
scrollToBottom(false);
await continueMessageAndGenerate(
viewingChat.conv.id,
msg.id,
content,
onChunk
);
setCurrNodeId(-1);
scrollToBottom(false);
};
const hasCanvas = !!canvasData;
useEffect(() => {
@@ -204,7 +219,7 @@ export default function ChatScreen() {
// due to some timing issues of StorageUtils.appendMsg(), we need to make sure the pendingMsg is not duplicated upon rendering (i.e. appears once in the saved conversation and once in the pendingMsg)
const pendingMsgDisplay: MessageDisplay[] =
pendingMsg && messages.at(-1)?.msg.id !== pendingMsg.id
pendingMsg && !messages.some((m) => m.msg.id === pendingMsg.id) // Only show if pendingMsg is not an existing message being continued
? [
{
msg: pendingMsg,
@@ -236,17 +251,35 @@ export default function ChatScreen() {
{/* placeholder to shift the message to the bottom */}
{viewingChat ? '' : 'Send a message to start'}
</div>
{[...messages, ...pendingMsgDisplay].map((msg) => (
<ChatMessage
key={msg.msg.id}
msg={msg.msg}
siblingLeafNodeIds={msg.siblingLeafNodeIds}
siblingCurrIdx={msg.siblingCurrIdx}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onChangeSibling={setCurrNodeId}
/>
))}
{[...messages, ...pendingMsgDisplay].map((msgDisplay) => {
const actualMsgObject = msgDisplay.msg;
// Check if the current message from the list is the one actively being generated/continued
const isThisMessageTheActivePendingOne =
pendingMsg?.id === actualMsgObject.id;
return (
<ChatMessage
key={actualMsgObject.id}
// If this message is the active pending one, use the live object from pendingMsg state (which has streamed content).
// Otherwise, use the version from the messages array (from storage).
msg={
isThisMessageTheActivePendingOne
? pendingMsg
: actualMsgObject
}
siblingLeafNodeIds={msgDisplay.siblingLeafNodeIds}
siblingCurrIdx={msgDisplay.siblingCurrIdx}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onChangeSibling={setCurrNodeId}
// A message is pending if it's the actively streaming one OR if it came from pendingMsgDisplay (for new messages)
isPending={
isThisMessageTheActivePendingOne || msgDisplay.isPending
}
onContinueMessage={handleContinueMessage}
/>
);
})}
</div>
{/* chat input */}

View File

@@ -5,6 +5,14 @@ import { classNames } from '../utils/misc';
import daisyuiThemes from 'daisyui/theme/object';
import { THEMES } from '../Config';
import { useNavigate } from 'react-router';
import toast from 'react-hot-toast';
import { useModals } from './ModalProvider';
import {
ArrowUpTrayIcon,
ArrowDownTrayIcon,
PencilIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
export default function Header() {
const navigate = useNavigate();
@@ -24,9 +32,11 @@ export default function Header() {
);
}, [selectedTheme]);
const {showPrompt } = useModals();
const { isGenerating, viewingChat } = useAppContext();
const isCurrConvGenerating = isGenerating(viewingChat?.conv.id ?? '');
// remove conversation
const removeConversation = () => {
if (isCurrConvGenerating || !viewingChat) return;
const convId = viewingChat?.conv.id;
@@ -35,6 +45,37 @@ export default function Header() {
navigate('/');
}
};
// rename conversation
async function renameConversation() {
if (isGenerating(viewingChat?.conv.id ?? '')) {
toast.error(
'Cannot rename conversation while generating'
);
return;
}
const newName = await showPrompt(
'Enter new name for the conversation',
viewingChat?.conv.name
);
if (newName && newName.trim().length > 0) {
StorageUtils.updateConversationName(viewingChat?.conv.id ?? '', newName);
}
//const importedConv = await StorageUtils.updateConversationName();
//if (importedConv) {
//console.log('Successfully imported:', importedConv.name);
// Refresh UI or navigate to conversation
//}
};
// at the top of your file, alongside ConversationExport:
async function importConversation() {
const importedConv = await StorageUtils.importConversationFromFile();
if (importedConv) {
console.log('Successfully imported:', importedConv.name);
// Refresh UI or navigate to conversation
}
};
const downloadConversation = () => {
if (isCurrConvGenerating || !viewingChat) return;
@@ -99,12 +140,45 @@ export default function Header() {
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li onClick={downloadConversation}>
<a>Download</a>
</li>
<li className="text-error" onClick={removeConversation}>
<a>Delete</a>
</li>
{/* Always show Upload when viewingChat is false */}
{!viewingChat && (
<li onClick={importConversation}>
<a>
<ArrowUpTrayIcon className="w-4 h-4" />
Upload
</a>
</li>
)}
{/* Show all three when viewingChat is true */}
{viewingChat && (
<>
<li onClick={importConversation}>
<a>
<ArrowUpTrayIcon className="w-4 h-4" />
Upload
</a>
</li>
<li onClick={renameConversation} tabIndex={0}>
<a>
<PencilIcon className="w-4 h-4" />
Rename
</a>
</li>
<li onClick={downloadConversation}>
<a>
<ArrowDownTrayIcon className="w-4 h-4" />
Download
</a>
</li>
<li className="text-error" onClick={removeConversation}>
<a>
<TrashIcon className="w-4 h-4" />
Delete
</a>
</li>
</>
)}
</ul>
</div>
)}

View File

@@ -0,0 +1,151 @@
import React, { createContext, useState, useContext } from 'react';
type ModalContextType = {
showConfirm: (message: string) => Promise<boolean>;
showPrompt: (
message: string,
defaultValue?: string
) => Promise<string | undefined>;
showAlert: (message: string) => Promise<void>;
};
const ModalContext = createContext<ModalContextType>(null!);
interface ModalState<T> {
isOpen: boolean;
message: string;
defaultValue?: string;
resolve: ((value: T) => void) | null;
}
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [confirmState, setConfirmState] = useState<ModalState<boolean>>({
isOpen: false,
message: '',
resolve: null,
});
const [promptState, setPromptState] = useState<
ModalState<string | undefined>
>({ isOpen: false, message: '', resolve: null });
const [alertState, setAlertState] = useState<ModalState<void>>({
isOpen: false,
message: '',
resolve: null,
});
const inputRef = React.useRef<HTMLInputElement>(null);
const showConfirm = (message: string): Promise<boolean> => {
return new Promise((resolve) => {
setConfirmState({ isOpen: true, message, resolve });
});
};
const showPrompt = (
message: string,
defaultValue?: string
): Promise<string | undefined> => {
return new Promise((resolve) => {
setPromptState({ isOpen: true, message, defaultValue, resolve });
});
};
const showAlert = (message: string): Promise<void> => {
return new Promise((resolve) => {
setAlertState({ isOpen: true, message, resolve });
});
};
const handleConfirm = (result: boolean) => {
confirmState.resolve?.(result);
setConfirmState({ isOpen: false, message: '', resolve: null });
};
const handlePrompt = (result?: string) => {
promptState.resolve?.(result);
setPromptState({ isOpen: false, message: '', resolve: null });
};
const handleAlertClose = () => {
alertState.resolve?.();
setAlertState({ isOpen: false, message: '', resolve: null });
};
return (
<ModalContext.Provider value={{ showConfirm, showPrompt, showAlert }}>
{children}
{/* Confirm Modal */}
{confirmState.isOpen && (
<dialog className="modal modal-open z-[1100]">
<div className="modal-box">
<h3 className="font-bold text-lg">{confirmState.message}</h3>
<div className="modal-action">
<button
className="btn btn-ghost"
onClick={() => handleConfirm(false)}
>
Cancel
</button>
<button
className="btn btn-error"
onClick={() => handleConfirm(true)}
>
Confirm
</button>
</div>
</div>
</dialog>
)}
{/* Prompt Modal */}
{promptState.isOpen && (
<dialog className="modal modal-open z-[1100]">
<div className="modal-box">
<h3 className="font-bold text-lg">{promptState.message}</h3>
<input
type="text"
className="input input-bordered w-full mt-2"
defaultValue={promptState.defaultValue}
ref={inputRef}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handlePrompt((e.target as HTMLInputElement).value);
}
}}
/>
<div className="modal-action">
<button className="btn btn-ghost" onClick={() => handlePrompt()}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={() => handlePrompt(inputRef.current?.value)}
>
Submit
</button>
</div>
</div>
</dialog>
)}
{/* Alert Modal */}
{alertState.isOpen && (
<dialog className="modal modal-open z-[1100]">
<div className="modal-box">
<h3 className="font-bold text-lg">{alertState.message}</h3>
<div className="modal-action">
<button className="btn" onClick={handleAlertClose}>
OK
</button>
</div>
</div>
</dialog>
)}
</ModalContext.Provider>
);
}
export function useModals() {
const context = useContext(ModalContext);
if (!context) throw new Error('useModals must be used within ModalProvider');
return context;
}

View File

@@ -3,16 +3,21 @@ import { useAppContext } from '../utils/app.context';
import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config';
import { isDev } from '../Config';
import StorageUtils from '../utils/storage';
import { useModals } from './ModalProvider';
import { classNames, isBoolean, isNumeric, isString } from '../utils/misc';
import {
BeakerIcon,
BookmarkIcon,
ChatBubbleOvalLeftEllipsisIcon,
Cog6ToothIcon,
FunnelIcon,
HandRaisedIcon,
SquaresPlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { OpenInNewTab } from '../utils/common';
import { SettingsPreset } from '../utils/types';
import toast from 'react-hot-toast'
type SettKey = keyof typeof CONFIG_DEFAULT;
@@ -74,7 +79,155 @@ interface SettingSection {
const ICON_CLASSNAME = 'w-4 h-4 mr-1 inline';
const SETTING_SECTIONS: SettingSection[] = [
// Presets Component
function PresetsManager({
currentConfig,
onLoadPreset,
}: {
currentConfig: typeof CONFIG_DEFAULT;
onLoadPreset: (config: typeof CONFIG_DEFAULT) => void;
}) {
const [presets, setPresets] = useState<SettingsPreset[]>(() =>
StorageUtils.getPresets()
);
const [presetName, setPresetName] = useState('');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const { showConfirm, showAlert } = useModals();
const handleSavePreset = async () => {
if (!presetName.trim()) {
await showAlert('Please enter a preset name');
return;
}
// Check if preset name already exists
const existingPreset = presets.find((p) => p.name === presetName.trim());
if (existingPreset) {
if (
await showConfirm(
`Preset "${presetName}" already exists. Do you want to overwrite it?`
)
) {
StorageUtils.updatePreset(existingPreset.id, currentConfig);
setPresets(StorageUtils.getPresets());
setPresetName('');
await showAlert('Preset updated successfully');
}
} else {
const newPreset = StorageUtils.savePreset(
presetName.trim(),
currentConfig
);
setPresets([...presets, newPreset]);
setPresetName('');
await showAlert('Preset saved successfully');
}
};
const handleLoadPreset = async (preset: SettingsPreset) => {
if (
await showConfirm(
`Load preset "${preset.name}"? Current settings will be replaced.`
)
) {
onLoadPreset(preset.config as typeof CONFIG_DEFAULT);
setSelectedPresetId(preset.id);
}
};
const handleDeletePreset = async (preset: SettingsPreset) => {
if (await showConfirm(`Delete preset "${preset.name}"?`)) {
StorageUtils.deletePreset(preset.id);
setPresets(presets.filter((p) => p.id !== preset.id));
if (selectedPresetId === preset.id) {
setSelectedPresetId(null);
}
}
};
return (
<div className="space-y-4">
{/* Save current settings as preset */}
<div className="form-control">
<label className="label">
<span className="label-text">Save current settings as preset</span>
</label>
<div className="join">
<input
type="text"
placeholder="Enter preset name"
className="input input-bordered join-item flex-1"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSavePreset();
}
}}
/>
<button
className="btn btn-primary join-item"
onClick={handleSavePreset}
>
Save Preset
</button>
</div>
</div>
{/* List of saved presets */}
<div className="form-control">
<label className="label">
<span className="label-text">Saved presets</span>
</label>
{presets.length === 0 ? (
<div className="alert">
<span>No presets saved yet</span>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{presets.map((preset) => (
<div
key={preset.id}
className={classNames({
'card bg-base-200 p-3': true,
'ring-2 ring-primary': selectedPresetId === preset.id,
})}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{preset.name}</h4>
<p className="text-sm opacity-70">
Created: {new Date(preset.createdAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2">
<button
className="btn btn-sm btn-primary"
onClick={() => handleLoadPreset(preset)}
>
Load
</button>
<button
className="btn btn-sm btn-error"
onClick={() => handleDeletePreset(preset)}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
const SETTING_SECTIONS = (
localConfig: typeof CONFIG_DEFAULT,
setLocalConfig: (config: typeof CONFIG_DEFAULT) => void
): SettingSection[] => [
{
title: (
<>
@@ -187,6 +340,85 @@ const SETTING_SECTIONS: SettingSection[] = [
);
},
},
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
component: () => {
const exportDB = async () => {
const blob = await StorageUtils.exportDB();
const a = document.createElement('a');
document.body.appendChild(a);
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.download = `llamawebui_dump.json`;
a.click();
document.body.removeChild(a);
};
return (
<button className="btn" onClick={exportDB}>
Export conversation database
</button>
);
},
},
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
component: () => {
const importDB = async (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e);
if (!e.target.files) {
toast.error('Target.files cant be null');
throw new Error('e.target.files cant be null');
}
if (e.target.files.length != 1)
{
toast.error(
'Number of selected files for DB import must be 1 but was ' +
e.target.files.length +
'.');
throw new Error(
'Number of selected files for DB import must be 1 but was ' +
e.target.files.length +
'.'
);
}
const file = e.target.files[0];
try {
if (!file) throw new Error('No DB found to import.');
console.log('Importing DB ' + file.name);
await StorageUtils.importDB(file);
toast.success('Import complete')
window.location.reload();
} catch (error) {
console.error('' + error);
toast.error('' + error);
}
};
return (
<div>
<label
htmlFor="db-import"
className="btn"
role="button"
tabIndex={0}
>
{' '}
Reset and import conversation database{' '}
</label>
<input
id="db-import"
type="file"
accept=".json"
className="file-upload"
onInput={importDB}
hidden
/>
</div>
);
},
},
{
type: SettingInputType.CHECKBOX,
label: 'Show tokens per second',
@@ -257,6 +489,26 @@ const SETTING_SECTIONS: SettingSection[] = [
},
],
},
{
title: (
<>
<BookmarkIcon className={ICON_CLASSNAME} />
Presets
</>
),
fields: [
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key for presets
component: () => (
<PresetsManager
currentConfig={localConfig}
onLoadPreset={setLocalConfig}
/>
),
},
],
},
];
export default function SettingDialog({
@@ -274,6 +526,12 @@ export default function SettingDialog({
JSON.parse(JSON.stringify(config))
);
// Generate sections with access to local state
const SETTING_SECTIONS_GENERATED = SETTING_SECTIONS(
localConfig,
setLocalConfig
);
const resetConfig = () => {
if (window.confirm('Are you sure you want to reset all settings?')) {
setLocalConfig(CONFIG_DEFAULT);
@@ -332,7 +590,7 @@ export default function SettingDialog({
<div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]">
{/* Left panel, showing sections - Desktop version */}
<div className="hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200">
{SETTING_SECTIONS.map((section, idx) => (
{SETTING_SECTIONS_GENERATED.map((section, idx) => (
<div
key={idx}
className={classNames({
@@ -351,10 +609,10 @@ export default function SettingDialog({
<div className="md:hidden flex flex-row gap-2 mb-4">
<details className="dropdown">
<summary className="btn bt-sm w-full m-1">
{SETTING_SECTIONS[sectionIdx].title}
{SETTING_SECTIONS_GENERATED[sectionIdx].title}
</summary>
<ul className="menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
{SETTING_SECTIONS.map((section, idx) => (
{SETTING_SECTIONS_GENERATED.map((section, idx) => (
<div
key={idx}
className={classNames({
@@ -373,7 +631,7 @@ export default function SettingDialog({
{/* Right panel, showing setting fields */}
<div className="grow overflow-y-auto px-4">
{SETTING_SECTIONS[sectionIdx].fields.map((field, idx) => {
{SETTING_SECTIONS_GENERATED[sectionIdx].fields.map((field, idx) => {
const key = `${sectionIdx}-${idx}`;
if (field.type === SettingInputType.SHORT_INPUT) {
return (

View File

@@ -1,13 +1,36 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { classNames } from '../utils/misc';
import { Conversation } from '../utils/types';
import StorageUtils from '../utils/storage';
import { useNavigate, useParams } from 'react-router';
import {
ArrowUpTrayIcon,
ArrowDownTrayIcon,
EllipsisVerticalIcon,
PencilIcon,
PencilSquareIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { BtnWithTooltips } from '../utils/common';
import { useAppContext } from '../utils/app.context';
import toast from 'react-hot-toast';
import { useModals } from './ModalProvider';
// at the top of your file, alongside ConversationExport:
async function importConversation() {
const importedConv = await StorageUtils.importConversationFromFile();
if (importedConv) {
console.log('Successfully imported:', importedConv.name);
// Refresh UI or navigate to conversation
}
};
export default function Sidebar() {
const params = useParams();
const navigate = useNavigate();
const { isGenerating, viewingChat } = useAppContext();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [currConv, setCurrConv] = useState<Conversation | null>(null);
@@ -25,68 +48,176 @@ export default function Sidebar() {
StorageUtils.offConversationChanged(handleConversationChange);
};
}, []);
const { showConfirm, showPrompt } = useModals();
const groupedConv = useMemo(
() => groupConversationsByDate(conversations),
[conversations]
);
return (
<>
<input
id="toggle-drawer"
type="checkbox"
className="drawer-toggle"
aria-label="Toggle sidebar"
defaultChecked
/>
<div className="drawer-side h-screen lg:h-screen z-50 lg:max-w-64">
<div
className="drawer-side h-screen lg:h-screen z-50 lg:max-w-64"
role="complementary"
aria-label="Sidebar"
tabIndex={0}
>
<label
htmlFor="toggle-drawer"
aria-label="close sidebar"
aria-label="Close sidebar"
className="drawer-overlay"
></label>
<a
href="#main-scroll"
className="absolute -left-80 top-0 w-1 h-1 overflow-hidden"
>
Skip to main content
</a>
<div className="flex flex-col bg-base-200 min-h-full max-w-64 py-4 px-4">
<div className="flex flex-row items-center justify-between mb-4 mt-4">
<h2 className="font-bold ml-4">Conversations</h2>
<h2 className="font-bold ml-4" role="heading">
Conversations
</h2>
{/* close sidebar button */}
<label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-bar-left"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5M10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5"
/>
</svg>
<label
htmlFor="toggle-drawer"
className="btn btn-ghost lg:hidden"
aria-label="Close sidebar"
role="button"
tabIndex={0}
>
<XMarkIcon className="w-5 h-5" />
</label>
</div>
{/* list of conversations */}
<div
{/* new conversation button */}
<button
className={classNames({
'btn btn-ghost justify-start': true,
'btn-active': !currConv,
'btn btn-ghost justify-start px-2': true,
'btn-soft': !currConv,
})}
onClick={() => navigate('/')}
aria-label="New conversation"
>
+ New conversation
</div>
{conversations.map((conv) => (
<div
key={conv.id}
className={classNames({
'btn btn-ghost justify-start font-normal': true,
'btn-active': conv.id === currConv?.id,
})}
onClick={() => navigate(`/chat/${conv.id}`)}
dir="auto"
>
<span className="truncate">{conv.name}</span>
<PencilSquareIcon className="w-5 h-5" />
New conversation
</button>
{/* list of conversations */}
{groupedConv.map((group, i) => (
<div key={i} role="group">
{/* group name (by date) */}
{group.title ? (
// we use btn class here to make sure that the padding/margin are aligned with the other items
<b
className="btn btn-ghost btn-xs bg-none btn-disabled block text-xs text-base-content text-start px-2 mb-0 mt-6 font-bold"
role="note"
aria-description={group.title}
tabIndex={0}
>
{group.title}
</b>
) : (
<div className="h-2" />
)}
{group.conversations.map((conv) => (
<ConversationItem
key={conv.id}
conv={conv}
isCurrConv={currConv?.id === conv.id}
onSelect={() => {
navigate(`/chat/${conv.id}`);
}}
onDelete={async () => {
if (isGenerating(conv.id)) {
toast.error(
'Cannot delete conversation while generating'
);
return;
}
if (
await showConfirm(
'Are you sure to delete this conversation?'
)
) {
toast.success('Conversation deleted');
StorageUtils.remove(conv.id);
navigate('/');
}
}}
onDownload={() => {
if (isGenerating(conv.id)) {
toast.error(
'Cannot download conversation while generating'
);
return;
}
// Get the current system message from config
const systemMessage = StorageUtils.getConfig().systemMessage;
// Clone the viewingChat object to avoid modifying the original
const exportData = {
conv: { ...viewingChat?.conv },
messages: viewingChat?.messages.map(msg => ({ ...msg }))
};
// Find the root message and update its content
const rootMessage = exportData.messages?.find(m => m.type === 'root');
if (rootMessage) {
rootMessage.content = systemMessage;
}
const conversationJson = JSON.stringify(exportData, null, 2);
// const conversationJson = JSON.stringify(conv, null, 2);
const blob = new Blob([conversationJson], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `conversation_${conv.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
onRename={async () => {
if (isGenerating(conv.id)) {
toast.error(
'Cannot rename conversation while generating'
);
return;
}
const newName = await showPrompt(
'Enter new name for the conversation',
conv.name
);
if (newName && newName.trim().length > 0) {
StorageUtils.updateConversationName(conv.id, newName);
}
}}
/>
))}
</div>
))}
<div className="text-center text-xs opacity-40 mt-auto mx-4">
<div className="text-center text-xs opacity-40 mt-auto mx-4 pt-8">
Conversations are saved to browser's IndexedDB
</div>
</div>
@@ -94,3 +225,182 @@ export default function Sidebar() {
</>
);
}
function ConversationItem({
conv,
isCurrConv,
onSelect,
onDelete,
onDownload,
onRename,
}: {
conv: Conversation;
isCurrConv: boolean;
onSelect: () => void;
onDelete: () => void;
onDownload: () => void;
onRename: () => void;
}) {
return (
<div
role="menuitem"
tabIndex={0}
aria-label={conv.name}
className={classNames({
'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9':
true,
'btn-soft': isCurrConv,
})}
>
<button
key={conv.id}
className="w-full overflow-hidden truncate text-start"
onClick={onSelect}
dir="auto"
>
{conv.name}
</button>
<div className="dropdown dropdown-end h-5">
<BtnWithTooltips
// on mobile, we always show the ellipsis icon
// on desktop, we only show it when the user hovers over the conversation item
// we use opacity instead of hidden to avoid layout shift
className="cursor-pointer opacity-100 md:opacity-0 group-hover:opacity-100"
onClick={() => {}}
tooltipsContent="More"
>
<EllipsisVerticalIcon className="w-5 h-5" />
</BtnWithTooltips>
{/* dropdown menu */}
<ul
aria-label="More options"
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow"
>
{/* Always show Upload when viewingChat is false */}
<li onClick={importConversation}>
<a>
<ArrowUpTrayIcon className="w-4 h-4" />
Upload
</a>
</li>
<li onClick={onRename} tabIndex={0}>
<a>
<PencilIcon className="w-4 h-4" />
Rename
</a>
</li>
<li onClick={onDownload} tabIndex={0}>
<a>
<ArrowDownTrayIcon className="w-4 h-4" />
Download
</a>
</li>
<li className="text-error" onClick={onDelete} tabIndex={0}>
<a>
<TrashIcon className="w-4 h-4" />
Delete
</a>
</li>
</ul>
</div>
</div>
);
}
// WARN: vibe code below
export interface GroupedConversations {
title?: string;
conversations: Conversation[];
}
// TODO @ngxson : add test for this function
// Group conversations by date
// - "Previous 7 Days"
// - "Previous 30 Days"
// - "Month Year" (e.g., "April 2023")
export function groupConversationsByDate(
conversations: Conversation[]
): GroupedConversations[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Start of today
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(today.getDate() - 7);
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
const groups: { [key: string]: Conversation[] } = {
Today: [],
'Previous 7 Days': [],
'Previous 30 Days': [],
};
const monthlyGroups: { [key: string]: Conversation[] } = {}; // Key format: "Month Year" e.g., "April 2023"
// Sort conversations by lastModified date in descending order (newest first)
// This helps when adding to groups, but the final output order of groups is fixed.
const sortedConversations = [...conversations].sort(
(a, b) => b.lastModified - a.lastModified
);
for (const conv of sortedConversations) {
const convDate = new Date(conv.lastModified);
if (convDate >= today) {
groups['Today'].push(conv);
} else if (convDate >= sevenDaysAgo) {
groups['Previous 7 Days'].push(conv);
} else if (convDate >= thirtyDaysAgo) {
groups['Previous 30 Days'].push(conv);
} else {
const monthName = convDate.toLocaleString('default', { month: 'long' });
const year = convDate.getFullYear();
const monthYearKey = `${monthName} ${year}`;
if (!monthlyGroups[monthYearKey]) {
monthlyGroups[monthYearKey] = [];
}
monthlyGroups[monthYearKey].push(conv);
}
}
const result: GroupedConversations[] = [];
if (groups['Today'].length > 0) {
result.push({
title: undefined, // no title for Today
conversations: groups['Today'],
});
}
if (groups['Previous 7 Days'].length > 0) {
result.push({
title: 'Previous 7 Days',
conversations: groups['Previous 7 Days'],
});
}
if (groups['Previous 30 Days'].length > 0) {
result.push({
title: 'Previous 30 Days',
conversations: groups['Previous 30 Days'],
});
}
// Sort monthly groups by date (most recent month first)
const sortedMonthKeys = Object.keys(monthlyGroups).sort((a, b) => {
const dateA = new Date(a); // "Month Year" can be parsed by Date constructor
const dateB = new Date(b);
return dateB.getTime() - dateA.getTime();
});
for (const monthKey of sortedMonthKeys) {
if (monthlyGroups[monthKey].length > 0) {
result.push({ title: monthKey, conversations: monthlyGroups[monthKey] });
}
}
return result;
}

View File

@@ -41,6 +41,10 @@ html {
max-width: 900px;
}
.chat-bubble {
@apply break-words;
}
.chat-bubble-base-300 {
--tw-bg-opacity: 1;
--tw-text-opacity: 1;

View File

@@ -15,7 +15,7 @@ import {
} from './misc';
import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
import { matchPath, useLocation, useNavigate } from 'react-router';
import toast from 'react-hot-toast';
class Timer {
static timercount = 1;
}
@@ -39,7 +39,12 @@ interface AppContextValue {
extra: Message['extra'],
onChunk: CallbackGeneratedChunk
) => Promise<void>;
continueMessageAndGenerate: (
convId: string,
messageIdToContinue: Message['id'],
newContent: string,
onChunk: CallbackGeneratedChunk
) => Promise<void>;
// canvas
canvasData: CanvasData | null;
setCanvasData: (data: CanvasData | null) => void;
@@ -136,7 +141,8 @@ export const AppContextProvider = ({
const generateMessage = async (
convId: string,
leafNodeId: Message['id'],
onChunk: CallbackGeneratedChunk
onChunk: CallbackGeneratedChunk,
isContinuation: boolean = false
) => {
if (isGenerating(convId)) return;
@@ -160,17 +166,36 @@ export const AppContextProvider = ({
const pendingId = Date.now() + Timer.timercount + 1;
Timer.timercount=Timer.timercount+2;
let pendingMsg: PendingMessage = {
id: pendingId,
convId,
type: 'text',
timestamp: pendingId,
role: 'assistant',
content: null,
parent: leafNodeId,
children: [],
};
setPending(convId, pendingMsg);
let pendingMsg: Message | PendingMessage;
if (isContinuation) {
const existingAsstMsg = await StorageUtils.getMessage(convId, leafNodeId);
if (!existingAsstMsg || existingAsstMsg.role !== 'assistant') {
toast.error(
'Cannot continue: target message not found or not an assistant message.'
);
throw new Error(
'Cannot continue: target message not found or not an assistant message.'
);
}
pendingMsg = {
...existingAsstMsg,
content: existingAsstMsg.content || '',
};
setPending(convId, pendingMsg as PendingMessage);
} else {
pendingMsg = {
id: pendingId,
convId,
type: 'text',
timestamp: pendingId,
role: 'assistant',
content: null,
parent: leafNodeId,
children: [],
};
setPending(convId, pendingMsg as PendingMessage);
}
try {
// prepare messages for API
@@ -254,7 +279,7 @@ export const AppContextProvider = ({
predicted_ms: timings.predicted_ms,
};
}
setPending(convId, pendingMsg);
setPending(convId, pendingMsg as PendingMessage);
onChunk(); // don't need to switch node for pending message
}
} catch (err) {
@@ -271,11 +296,16 @@ export const AppContextProvider = ({
}
finally {
if (pendingMsg.content !== null) {
await StorageUtils.appendMsg(pendingMsg as Message, leafNodeId);
if (isContinuation) {
await StorageUtils.updateMessage(pendingMsg as Message);
} else if (pendingMsg.content.trim().length > 0) {
await StorageUtils.appendMsg(pendingMsg as Message, leafNodeId);
}
}
}
setPending(convId, null);
onChunk(pendingId); // trigger scroll to bottom and switch to the last node
const finalNodeId = (pendingMsg as Message).id;
onChunk(finalNodeId); // trigger scroll to bottom and switch to the last node
};
const sendMessage = async (
@@ -317,7 +347,7 @@ export const AppContextProvider = ({
onChunk(currMsgId);
try {
await generateMessage(convId, currMsgId, onChunk);
await generateMessage(convId, currMsgId, onChunk, false);
return true;
} catch (_) {
// TODO: rollback
@@ -364,6 +394,38 @@ export const AppContextProvider = ({
await generateMessage(convId, parentNodeId, onChunk);
};
const continueMessageAndGenerate = async (
convId: string,
messageIdToContinue: Message['id'],
newContent: string,
onChunk: CallbackGeneratedChunk
) => {
if (isGenerating(convId)) return;
const existingMessage = await StorageUtils.getMessage(
convId,
messageIdToContinue
);
if (!existingMessage || existingMessage.role !== 'assistant') {
console.error(
'Cannot continue non-assistant message or message not found'
);
toast.error(
'Failed to continue message: Not an assistant message or not found.'
);
return;
}
const updatedAssistantMessage: Message = {
...existingMessage,
content: newContent,
};
//children: [], // Clear existing children to start a new branch of generation
await StorageUtils.updateMessage(updatedAssistantMessage);
onChunk;
};
const saveConfig = (config: typeof CONFIG_DEFAULT) => {
StorageUtils.setConfig(config);
setConfig(config);
@@ -378,6 +440,7 @@ export const AppContextProvider = ({
sendMessage,
stopGenerating,
replaceMessageAndGenerate,
continueMessageAndGenerate,
canvasData,
setCanvasData,
config,

View File

@@ -36,3 +36,39 @@ export const OpenInNewTab = ({
{children}
</a>
);
export function BtnWithTooltips({
className,
onClick,
onMouseLeave,
children,
tooltipsContent,
disabled,
}: {
className?: string;
onClick: () => void;
onMouseLeave?: () => void;
children: React.ReactNode;
tooltipsContent: string;
disabled?: boolean;
}) {
// the onClick handler is on the container, so screen readers can safely ignore the inner button
// this prevents the label from being read twice
return (
<div
className="tooltip tooltip-bottom"
data-tip={tooltipsContent}
role="button"
onClick={onClick}
>
<button
className={`${className ?? ''} flex items-center justify-center`}
disabled={disabled}
onMouseLeave={onMouseLeave}
aria-hidden={true}
>
{children}
</button>
</div>
);
}

View File

@@ -2,8 +2,9 @@
// format: { [convId]: { id: string, lastModified: number, messages: [...] } }
import { CONFIG_DEFAULT } from '../Config';
import { Conversation, Message, TimingReport } from './types';
import { Conversation, Message, TimingReport, SettingsPreset } from './types';
import Dexie, { Table } from 'dexie';
import { exportDB as exportDexieDB } from 'dexie-export-import';
const event = new EventTarget();
@@ -32,6 +33,27 @@ db.version(1).stores({
// convId is a string prefixed with 'conv-'
const StorageUtils = {
async exportDB() {
return await exportDexieDB(db);
},
async importDB(file: File) {
await db.delete();
await db.open();
return await db.import(file);
},
/**
* update the name of a conversation
*/
async updateConversationName(convId: string, name: string): Promise<void> {
await db.conversations.update(convId, {
name,
// lastModified: Date.now(), Don't update modified date
});
dispatchConversationChange(convId);
},
/**
* manage conversations
*/
@@ -203,8 +225,182 @@ const StorageUtils = {
localStorage.setItem('theme', theme);
}
},
// Add to StorageUtils object
// Add this to the StorageUtils object
async importConversation(importedData: {
conv: Conversation;
messages: Message[];
}): Promise<Conversation> {
const { conv, messages } = importedData;
// Check for existing conversation ID
let newConvId = conv.id;
const existing = await StorageUtils.getOneConversation(newConvId);
if (existing) {
newConvId = `conv-${Date.now()}`;
}
// Create ID mapping for messages
const idMap = new Map<number, number>();
const baseId = Date.now();
messages.forEach((msg, index) => {
idMap.set(msg.id, baseId + index);
});
// Create a mutable copy of messages
const updatedMessages = messages.map(msg => ({ ...msg }));
// Find root message before we process IDs
const rootMessage = updatedMessages.find(m => m.type === 'root');
// Ask user about system prompt update BEFORE processing IDs
let shouldUpdateSystemPrompt = false;
if (rootMessage) {
shouldUpdateSystemPrompt = confirm(
`This conversation contains a system prompt:\n\n"${rootMessage.content.slice(0, 100)}${rootMessage.content.length > 100 ? '...' : ''}"\n\nUpdate your system settings to use this prompt?`
);
}
// Now update messages with new IDs
updatedMessages.forEach(msg => {
msg.id = idMap.get(msg.id)!;
msg.convId = newConvId;
msg.parent = msg.parent === -1 ? -1 : (idMap.get(msg.parent) ?? -1);
msg.children = msg.children.map(childId => idMap.get(childId)!);
});
// Create new conversation with updated IDs
const conversation: Conversation = {
...conv,
id: newConvId,
currNode: idMap.get(conv.currNode) || updatedMessages[0]?.id || -1
};
// Update system prompt ONLY if user confirmed
if (shouldUpdateSystemPrompt && rootMessage) {
const config = StorageUtils.getConfig();
config.systemMessage = rootMessage.content || '';
StorageUtils.setConfig(config);
}
// Insert in transaction
await db.transaction('rw', db.conversations, db.messages, async () => {
await db.conversations.add(conversation);
await db.messages.bulkAdd(updatedMessages);
});
// Store conversation ID for post-refresh navigation
//localStorage.setItem('postImportNavigation', newConvId);
// Refresh the page to apply changes
window.location.reload();
return conversation;
},
/**
* Open file dialog and import conversation from JSON file
* @returns Promise resolving to imported conversation or null
*/
async importConversationFromFile(): Promise<Conversation | null> {
return new Promise((resolve) => {
// Create invisible file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,application/json';
fileInput.style.display = 'none';
fileInput.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {
resolve(null);
return;
}
try {
const fileText = await file.text();
const jsonData = JSON.parse(fileText);
// Validate JSON structure
if (!jsonData.conv || !jsonData.messages) {
throw new Error('Invalid conversation format');
}
const conversation = await StorageUtils.importConversation(jsonData);
resolve(conversation);
} catch (error) {
console.error('Import failed:', error);
alert(`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
resolve(null);
} finally {
document.body.removeChild(fileInput);
}
};
// Add to DOM and trigger click
document.body.appendChild(fileInput);
fileInput.click();
});
},
// get message
async getMessage(
convId: string,
messageId: Message['id']
): Promise<Message | undefined> {
return await db.messages.where({ convId, id: messageId }).first();
},
async updateMessage(updatedMessage: Message): Promise<void> {
await db.transaction('rw', db.conversations, db.messages, async () => {
await db.messages.put(updatedMessage);
await db.conversations.update(updatedMessage.convId, {
lastModified: Date.now(),
currNode: updatedMessage.id,
});
});
dispatchConversationChange(updatedMessage.convId);
},
// manage presets
getPresets(): SettingsPreset[] {
const presetsJson = localStorage.getItem('presets');
if (!presetsJson) return [];
try {
return JSON.parse(presetsJson);
} catch (e) {
console.error('Failed to parse presets', e);
return [];
}
},
savePreset(name: string, config: typeof CONFIG_DEFAULT): SettingsPreset {
const presets = StorageUtils.getPresets();
const now = Date.now();
const preset: SettingsPreset = {
id: `preset-${now}`,
name,
createdAt: now,
config: { ...config }, // copy the config
};
presets.push(preset);
localStorage.setItem('presets', JSON.stringify(presets));
return preset;
},
updatePreset(id: string, config: typeof CONFIG_DEFAULT): void {
const presets = StorageUtils.getPresets();
const index = presets.findIndex((p) => p.id === id);
if (index !== -1) {
presets[index].config = { ...config };
localStorage.setItem('presets', JSON.stringify(presets));
}
},
deletePreset(id: string): void {
const presets = StorageUtils.getPresets();
const filtered = presets.filter((p) => p.id !== id);
localStorage.setItem('presets', JSON.stringify(filtered));
},
};
export default StorageUtils;
// Migration from localStorage to IndexedDB

View File

@@ -89,3 +89,11 @@ export interface CanvasPyInterpreter {
}
export type CanvasData = CanvasPyInterpreter;
export interface SettingsPreset {
id: string; // format: `preset-{timestamp}`
name: string;
createdAt: number; // timestamp from Date.now()
config: Record<string, string | number | boolean>; // partial CONFIG_DEFAULT
}