Files
odidere/internal/service/static/edit.js
2026-07-05 00:48:56 +00:00

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;
}
}