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 f989fb03bd
commit d44c2d3f5a
17 changed files with 1560 additions and 163 deletions

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
}