mirror of
https://github.com/ikawrakow/ik_llama.cpp.git
synced 2026-03-12 06:50:08 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user