summaryrefslogtreecommitdiff
path: root/examples/server/webui/src/utils
diff options
context:
space:
mode:
authorfirecoperana <xuqiaowei1124@gmail.com>2025-07-20 05:33:55 -0500
committerGitHub <noreply@github.com>2025-07-20 12:33:55 +0200
commitd44c2d3f5aeab25a9405896f48a36082cee5d8ac (patch)
tree6768d4d8c72fb0b5c7b4a5a4187d2eccb292f0ad /examples/server/webui/src/utils
parentf989fb03bd12752ad6e93717ca4bd298d5001d99 (diff)
Webui: New Features for Conversations, Settings, and Chat Messages (#618)main
* 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>
Diffstat (limited to 'examples/server/webui/src/utils')
-rw-r--r--examples/server/webui/src/utils/app.context.tsx99
-rw-r--r--examples/server/webui/src/utils/common.tsx36
-rw-r--r--examples/server/webui/src/utils/storage.ts198
-rw-r--r--examples/server/webui/src/utils/types.ts8
4 files changed, 322 insertions, 19 deletions
diff --git a/examples/server/webui/src/utils/app.context.tsx b/examples/server/webui/src/utils/app.context.tsx
index ce81fccd..509b4ee0 100644
--- a/examples/server/webui/src/utils/app.context.tsx
+++ b/examples/server/webui/src/utils/app.context.tsx
@@ -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,
diff --git a/examples/server/webui/src/utils/common.tsx b/examples/server/webui/src/utils/common.tsx
index 09b08b5c..f664f6e8 100644
--- a/examples/server/webui/src/utils/common.tsx
+++ b/examples/server/webui/src/utils/common.tsx
@@ -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>
+ );
+} \ No newline at end of file
diff --git a/examples/server/webui/src/utils/storage.ts b/examples/server/webui/src/utils/storage.ts
index 1dfc9d97..88b1fef4 100644
--- a/examples/server/webui/src/utils/storage.ts
+++ b/examples/server/webui/src/utils/storage.ts
@@ -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
diff --git a/examples/server/webui/src/utils/types.ts b/examples/server/webui/src/utils/types.ts
index 0eb77400..2edfc8f3 100644
--- a/examples/server/webui/src/utils/types.ts
+++ b/examples/server/webui/src/utils/types.ts
@@ -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
+} \ No newline at end of file