Delete and rename conversations
This commit is contained in:
@@ -90,6 +90,22 @@ export class Api {
|
||||
return this._json('/conversations', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/**
|
||||
* renameConversation sets a new title for a conversation. The
|
||||
* server trims the title and rejects an empty one. The returned
|
||||
* row holds the saved title; the caller uses it to update the
|
||||
* picker.
|
||||
* @param {string} id
|
||||
* @param {string} title
|
||||
* @returns {Promise<Object>} the saved conversation row.
|
||||
*/
|
||||
async renameConversation(id, title) {
|
||||
return this._json(`/conversations/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteConversation removes the conversation, all its messages,
|
||||
* and its tool execution records. It also cancels an ongoing
|
||||
@@ -199,7 +215,7 @@ export class Api {
|
||||
async updateMessage(id, mid, body) {
|
||||
return this._json(
|
||||
`/conversations/${encodeURIComponent(id)}` +
|
||||
`/messages/${encodeURIComponent(mid)}`,
|
||||
`/messages/${encodeURIComponent(mid)}`,
|
||||
{ method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
@@ -215,7 +231,7 @@ export class Api {
|
||||
async deleteMessage(id, mid, updatedAt) {
|
||||
await this._request(
|
||||
`/conversations/${encodeURIComponent(id)}` +
|
||||
`/messages/${encodeURIComponent(mid)}`,
|
||||
`/messages/${encodeURIComponent(mid)}`,
|
||||
{ method: 'DELETE', body: { updated_at: updatedAt } },
|
||||
);
|
||||
}
|
||||
@@ -234,8 +250,8 @@ export class Api {
|
||||
async updateToolCall(id, mid, cid, body) {
|
||||
return this._json(
|
||||
`/conversations/${encodeURIComponent(id)}` +
|
||||
`/messages/${encodeURIComponent(mid)}` +
|
||||
`/calls/${encodeURIComponent(cid)}`,
|
||||
`/messages/${encodeURIComponent(mid)}` +
|
||||
`/calls/${encodeURIComponent(cid)}`,
|
||||
{ method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
@@ -252,8 +268,8 @@ export class Api {
|
||||
async deleteToolCall(id, mid, cid, updatedAt) {
|
||||
await this._request(
|
||||
`/conversations/${encodeURIComponent(id)}` +
|
||||
`/messages/${encodeURIComponent(mid)}` +
|
||||
`/calls/${encodeURIComponent(cid)}`,
|
||||
`/messages/${encodeURIComponent(mid)}` +
|
||||
`/calls/${encodeURIComponent(cid)}`,
|
||||
{ method: 'DELETE', body: { updated_at: updatedAt } },
|
||||
);
|
||||
}
|
||||
@@ -380,7 +396,7 @@ export async function* readEvents(response) {
|
||||
if (result.done) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => { });
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,17 @@ class Odidere {
|
||||
onSettingsOpen: () =>
|
||||
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
|
||||
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
|
||||
onConversationsOpen: () => this.loadConversations(),
|
||||
onConversationSearch: (query) => this.filterConversations(query),
|
||||
onConversationsOpen: () => {
|
||||
this.conversationQuery = '';
|
||||
this.loadConversations();
|
||||
},
|
||||
onConversationSearch: (query) => {
|
||||
this.conversationQuery = query;
|
||||
this.filterConversations(query);
|
||||
},
|
||||
onSelectConversation: (id) => this.switchConversation(id),
|
||||
onRenameConversation: (id) => this.renameConversation(id),
|
||||
onDeleteConversation: (id) => this.deleteConversation(id),
|
||||
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
||||
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
||||
onTranscribeStart: () => this.handleTranscribeStart(),
|
||||
@@ -111,6 +119,9 @@ class Odidere {
|
||||
this.conversationId = null;
|
||||
// The rows the conversation picker shows, fetched on open.
|
||||
this.conversations = [];
|
||||
// The picker's current search text. A rename or delete keeps
|
||||
// it, so the list re-renders with the same filter.
|
||||
this.conversationQuery = '';
|
||||
this.messages = new Map();
|
||||
this.drafts = new Map();
|
||||
this.busy = false;
|
||||
@@ -697,7 +708,7 @@ class Odidere {
|
||||
if (this.currentRun) {
|
||||
try {
|
||||
await this.currentRun;
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
if (id) {
|
||||
try {
|
||||
@@ -708,6 +719,16 @@ class Odidere {
|
||||
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
|
||||
}
|
||||
}
|
||||
this.clearLocalConversation();
|
||||
}
|
||||
|
||||
/**
|
||||
* clearLocalConversation drops every local trace of the selected
|
||||
* conversation: the cached messages, the drafts, the storage, and
|
||||
* the chat view. It sends no request. reset and the picker's
|
||||
* delete call it once the conversation is gone on the server.
|
||||
*/
|
||||
clearLocalConversation() {
|
||||
this.messages.clear();
|
||||
this.drafts.clear();
|
||||
this.storage.clearConversation();
|
||||
@@ -794,7 +815,7 @@ class Odidere {
|
||||
if (this.currentRun) {
|
||||
try {
|
||||
await this.currentRun;
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,6 +835,61 @@ class Odidere {
|
||||
this.updateSendEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* renameConversation asks the user for a new title, saves it, and
|
||||
* re-renders the picker with the current search filter. The saved
|
||||
* row comes from the server, so the list shows exactly what was
|
||||
* stored.
|
||||
* @param {string} id
|
||||
*/
|
||||
async renameConversation(id) {
|
||||
const row = this.conversations.find((c) => c.id === id);
|
||||
if (!row) return;
|
||||
const title = await this.ui.editConversationTitle(row.title ?? '');
|
||||
if (title == null) return;
|
||||
try {
|
||||
const saved = await this.api.renameConversation(id, title);
|
||||
row.title = saved.title;
|
||||
} catch (error) {
|
||||
this.ui.renderError(`Failed to rename conversation: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
this.filterConversations(this.conversationQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteConversation removes one conversation from the server and
|
||||
* from the picker. The two-press confirmation in the UI is the
|
||||
* only guard. Deleting the conversation in view also clears the
|
||||
* local state, like reset does.
|
||||
* @param {string} id
|
||||
*/
|
||||
async deleteConversation(id) {
|
||||
try {
|
||||
await this.api.deleteConversation(id);
|
||||
} catch (error) {
|
||||
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
this.conversations = this.conversations.filter((c) => c.id !== id);
|
||||
if (id === this.conversationId) {
|
||||
// The server-side delete cancels an ongoing inference. Detach
|
||||
// the local streams before the state goes, so the aborted
|
||||
// run's cleanup does not open a new subscription.
|
||||
this.tts.stop();
|
||||
this.conversationId = null;
|
||||
this.syncController?.abort();
|
||||
this.runController?.abort();
|
||||
if (this.currentRun) {
|
||||
try {
|
||||
await this.currentRun;
|
||||
} catch {}
|
||||
}
|
||||
this.clearLocalConversation();
|
||||
}
|
||||
this.filterConversations(this.conversationQuery);
|
||||
}
|
||||
|
||||
// ====================
|
||||
// SYNC
|
||||
// ====================
|
||||
@@ -1457,7 +1533,7 @@ class Odidere {
|
||||
this.ui.setLocationActive(true);
|
||||
this.updateSendEnabled();
|
||||
},
|
||||
() => { },
|
||||
() => {},
|
||||
GEOLOCATION_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export class UI {
|
||||
this.$tplConversationItem = document.getElementById(
|
||||
'tpl-conversation-item',
|
||||
);
|
||||
this.$tplRenameModal = document.getElementById('tpl-rename-modal');
|
||||
|
||||
// Compose settings modal.
|
||||
this.$composeSettings = document.getElementById('compose-settings');
|
||||
@@ -904,6 +905,10 @@ export class UI {
|
||||
$item.dataset.conversationId = conversation.id;
|
||||
$item.querySelector('.conversations__item-title').textContent =
|
||||
conversationLabel(conversation);
|
||||
$item.querySelector('.conversations__item-created').textContent =
|
||||
`Created ${CONVERSATION_DATE.format(
|
||||
new Date(conversation.created_at),
|
||||
)}`;
|
||||
$item.querySelector('.conversations__item-updated').textContent =
|
||||
`Updated ${CONVERSATION_DATE.format(
|
||||
new Date(conversation.updated_at),
|
||||
@@ -951,6 +956,27 @@ export class UI {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* editConversationTitle opens the rename modal with the current
|
||||
* title. It resolves with the trimmed new title when saved, and
|
||||
* with null when cancelled. An empty title is not a rename, so
|
||||
* the modal stays open until the user enters one or cancels.
|
||||
* @param {string} title
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
editConversationTitle(title) {
|
||||
const $modal =
|
||||
this.$tplRenameModal.content.cloneNode(true).firstElementChild;
|
||||
$modal.querySelector('#rename-input').value = title;
|
||||
return this._openModal($modal, ($modal) => {
|
||||
const value = $modal.querySelector('#rename-input').value.trim();
|
||||
if (value === '') {
|
||||
throw new Error('the title must not be empty');
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* editToolCall opens the tool edit modal, which edits the call's
|
||||
* name and arguments and, when a result exists, its text. It
|
||||
@@ -1143,7 +1169,24 @@ export class UI {
|
||||
this.$conversationsList.addEventListener('click', (event) => {
|
||||
const $item = event.target.closest('[data-conversation-id]');
|
||||
if (!$item) return;
|
||||
this.handlers.onSelectConversation($item.dataset.conversationId);
|
||||
const id = $item.dataset.conversationId;
|
||||
const $rename = event.target.closest('.conversations__item-rename');
|
||||
if ($rename) {
|
||||
this._clearConfirm();
|
||||
this.handlers.onRenameConversation(id);
|
||||
return;
|
||||
}
|
||||
const $delete = event.target.closest('.conversations__item-delete');
|
||||
if ($delete) {
|
||||
// Deleting is irreversible: the second press on the same
|
||||
// button deletes, any other click cancels the request.
|
||||
this._confirm(`conversation:${id}`, $delete, 'pending', () =>
|
||||
this.handlers.onDeleteConversation(id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._clearConfirm();
|
||||
this.handlers.onSelectConversation(id);
|
||||
});
|
||||
|
||||
// Compose settings modal.
|
||||
@@ -1474,15 +1517,17 @@ export class UI {
|
||||
value = readValue($modal);
|
||||
} catch (error) {
|
||||
console.error('invalid input:', error);
|
||||
for (const $textarea of $modal.querySelectorAll('.modal__textarea')) {
|
||||
$textarea.classList.add('modal__textarea--error');
|
||||
for (const $field of $modal.querySelectorAll(
|
||||
'.modal__textarea, .modal__input',
|
||||
)) {
|
||||
$field.classList.add('modal__textarea--error');
|
||||
}
|
||||
$save.classList.add('modal__save--error');
|
||||
setTimeout(() => {
|
||||
for (const $textarea of $modal.querySelectorAll(
|
||||
'.modal__textarea',
|
||||
for (const $field of $modal.querySelectorAll(
|
||||
'.modal__textarea, .modal__input',
|
||||
)) {
|
||||
$textarea.classList.remove('modal__textarea--error');
|
||||
$field.classList.remove('modal__textarea--error');
|
||||
}
|
||||
$save.classList.remove('modal__save--error');
|
||||
}, 500);
|
||||
@@ -1491,7 +1536,7 @@ export class UI {
|
||||
close(value);
|
||||
});
|
||||
|
||||
$modal.querySelector('textarea')?.focus();
|
||||
$modal.querySelector('textarea, input')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user