diff options
Diffstat (limited to 'examples/server/webui/src/utils')
-rw-r--r-- | examples/server/webui/src/utils/app.context.tsx | 99 | ||||
-rw-r--r-- | examples/server/webui/src/utils/common.tsx | 36 | ||||
-rw-r--r-- | examples/server/webui/src/utils/storage.ts | 198 | ||||
-rw-r--r-- | examples/server/webui/src/utils/types.ts | 8 |
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 |