214 lines
6.4 KiB
JavaScript
214 lines
6.4 KiB
JavaScript
/**
|
|
* EditModal opens a modal for editing a message (text, reasoning, or
|
|
* tool call). Reads from and writes to the conversation tree. Returns
|
|
* a Promise so the App knows when to re-render.
|
|
*/
|
|
|
|
import { extractText } from './conversation.js';
|
|
|
|
export class EditModal {
|
|
/**
|
|
* @param {Object} options
|
|
* @param {Document} options.document
|
|
* @param {import('./conversation.js').ConversationTree} options.conversation
|
|
*/
|
|
constructor({ document, conversation }) {
|
|
this.document = document;
|
|
this.conversation = conversation;
|
|
}
|
|
|
|
/**
|
|
* open creates and shows the edit modal for a message.
|
|
* @param {string} messageId
|
|
* @param {string} [field] - Optional: 'reasoning' or 'tool'
|
|
* @param {string} [toolCallId] - Optional, for tool edits
|
|
* @returns {Promise<boolean>} true if saved, false if cancelled
|
|
*/
|
|
open(messageId, field = null, toolCallId = null) {
|
|
const msg = this.conversation.get(messageId);
|
|
if (!msg) return Promise.resolve(false);
|
|
|
|
return new Promise((resolve) => {
|
|
const isToolEdit = field === 'tool';
|
|
const tplId = isToolEdit ? 'tpl-edit-tool-modal' : 'tpl-edit-modal';
|
|
const $tpl = this.document.getElementById(tplId);
|
|
if (!$tpl) return resolve(false);
|
|
|
|
const $modal = $tpl.content.cloneNode(true).firstElementChild;
|
|
const $overlay = this.document.createElement('div');
|
|
$overlay.className = 'modal-overlay';
|
|
$overlay.appendChild($modal);
|
|
|
|
if (!isToolEdit) {
|
|
$modal.querySelector('h2').textContent =
|
|
field === 'reasoning' ? 'Edit Reasoning' : 'Edit Message';
|
|
}
|
|
|
|
const saveBtn = $modal.querySelector('#edit-save');
|
|
const closeBtn = $modal.querySelector('.modal__close');
|
|
|
|
// Populate textarea(s).
|
|
if (isToolEdit && toolCallId) {
|
|
this._populateToolEdit(msg, toolCallId, $modal);
|
|
} else if (field === 'reasoning') {
|
|
const textarea = $modal.querySelector('#edit-textarea');
|
|
textarea.value = msg.reasoning_content || '';
|
|
} else {
|
|
const textarea = $modal.querySelector('#edit-textarea');
|
|
textarea.value = extractText(msg.content);
|
|
}
|
|
|
|
const close = () => {
|
|
$overlay.remove();
|
|
this.document.removeEventListener('keydown', handleEscape);
|
|
resolve(false);
|
|
};
|
|
|
|
const save = () => {
|
|
const saved = this._save(messageId, $modal, field, toolCallId, saveBtn);
|
|
if (saved) {
|
|
$overlay.remove();
|
|
this.document.removeEventListener('keydown', handleEscape);
|
|
resolve(true);
|
|
}
|
|
};
|
|
|
|
closeBtn.addEventListener('click', close);
|
|
saveBtn.addEventListener('click', save);
|
|
|
|
$overlay.addEventListener('click', (e) => {
|
|
if (e.target === $overlay) close();
|
|
});
|
|
|
|
const handleEscape = (e) => {
|
|
if (e.key === 'Escape') {
|
|
close();
|
|
}
|
|
};
|
|
this.document.addEventListener('keydown', handleEscape);
|
|
|
|
this.document.body.appendChild($overlay);
|
|
|
|
const firstTextarea = $modal.querySelector('textarea');
|
|
if (firstTextarea) firstTextarea.focus();
|
|
});
|
|
}
|
|
|
|
// --- Private ---
|
|
|
|
_populateToolEdit(msg, toolCallId, $modal) {
|
|
const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId);
|
|
if (!toolCall) return;
|
|
|
|
const $callTextarea = $modal.querySelector('#edit-tool-call');
|
|
const $resultTextarea = $modal.querySelector('#edit-tool-result');
|
|
|
|
try {
|
|
const args = JSON.parse(toolCall.function?.arguments || '{}');
|
|
$callTextarea.value = JSON.stringify(
|
|
{ name: toolCall.function?.name, arguments: args },
|
|
null,
|
|
2,
|
|
);
|
|
} catch {
|
|
$callTextarea.value = JSON.stringify(
|
|
{
|
|
name: toolCall.function?.name,
|
|
arguments: toolCall.function?.arguments || '',
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
for (const [, m] of this.conversation.messagesMap) {
|
|
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
|
|
$resultTextarea.value = extractText(m.content) || '';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
_save(messageId, $modal, field, toolCallId, saveBtn) {
|
|
const msg = this.conversation.get(messageId);
|
|
if (!msg) return false;
|
|
|
|
if (field === 'tool' && toolCallId) {
|
|
return this._saveToolEdit(msg, toolCallId, $modal, saveBtn);
|
|
}
|
|
|
|
if (field === 'reasoning') {
|
|
const textarea = $modal.querySelector('#edit-textarea');
|
|
msg.reasoning_content = textarea.value;
|
|
this.conversation.save();
|
|
return true;
|
|
}
|
|
|
|
// Regular message edit.
|
|
const textarea = $modal.querySelector('#edit-textarea');
|
|
const newText = textarea.value;
|
|
if (Array.isArray(msg.content)) {
|
|
const textPart = msg.content.find((p) => p.type === 'text');
|
|
if (textPart) {
|
|
textPart.text = newText;
|
|
} else {
|
|
msg.content.unshift({ type: 'text', text: newText });
|
|
}
|
|
} else {
|
|
msg.content = newText;
|
|
}
|
|
this.conversation.save();
|
|
return true;
|
|
}
|
|
|
|
_saveToolEdit(msg, toolCallId, $modal, saveBtn) {
|
|
const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId);
|
|
if (!toolCall) return false;
|
|
|
|
const $callTextarea = $modal.querySelector('#edit-tool-call');
|
|
const $resultTextarea = $modal.querySelector('#edit-tool-result');
|
|
|
|
let parsedCall;
|
|
try {
|
|
parsedCall = JSON.parse($callTextarea.value);
|
|
} catch {
|
|
$callTextarea.classList.add('modal__textarea--error');
|
|
saveBtn.classList.add('modal__save--error');
|
|
setTimeout(() => {
|
|
saveBtn.classList.remove('modal__save--error');
|
|
}, 500);
|
|
return false;
|
|
}
|
|
|
|
if (parsedCall.name) {
|
|
toolCall.function.name = parsedCall.name;
|
|
}
|
|
if (parsedCall.arguments !== undefined) {
|
|
toolCall.function.arguments =
|
|
typeof parsedCall.arguments === 'string'
|
|
? parsedCall.arguments
|
|
: JSON.stringify(parsedCall.arguments);
|
|
}
|
|
|
|
for (const [, m] of this.conversation.messagesMap) {
|
|
if (m.role === 'tool' && m.tool_call_id === toolCallId) {
|
|
const newText = $resultTextarea.value;
|
|
if (Array.isArray(m.content)) {
|
|
const textPart = m.content.find((p) => p.type === 'text');
|
|
if (textPart) {
|
|
textPart.text = newText;
|
|
} else {
|
|
m.content.unshift({ type: 'text', text: newText });
|
|
}
|
|
} else {
|
|
m.content = newText;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.conversation.save();
|
|
return true;
|
|
}
|
|
}
|