Refactor UI
This commit is contained in:
213
internal/service/static/edit.js
Normal file
213
internal/service/static/edit.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user