Add conversations modal

This commit is contained in:
dwrz
2026-10-30 23:06:25 +00:00
parent e1457b5130
commit 9020167a92
7 changed files with 429 additions and 1 deletions
+99 -1
View File
@@ -19,7 +19,7 @@ import { Api, ApiError } from './api.js';
import { Storage } from './storage.js';
import { STT, VOICE_MAP } from './stt.js';
import { TTSPlayer } from './tts.js';
import { extractText, UI } from './ui.js';
import { conversationLabel, extractText, UI } from './ui.js';
import { WakeLock } from './wakelock.js';
// The tool result saved when the user denies a tool call. A denied
@@ -69,6 +69,9 @@ class Odidere {
onSettingsOpen: () =>
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
onConversationsOpen: () => this.loadConversations(),
onConversationSearch: (query) => this.filterConversations(query),
onSelectConversation: (id) => this.switchConversation(id),
onPushToTalkStart: () => this.handlePushToTalkStart(),
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
onTranscribeStart: () => this.handleTranscribeStart(),
@@ -106,6 +109,8 @@ class Odidere {
// --- In-memory state ---
this.conversationId = null;
// The rows the conversation picker shows, fetched on open.
this.conversations = [];
this.messages = new Map();
this.drafts = new Map();
this.busy = false;
@@ -716,6 +721,99 @@ class Odidere {
this.updateSendEnabled();
}
// ====================
// CONVERSATIONS
// ====================
/**
* loadConversations fetches the list for the picker and renders
* it. The server sends the rows most recently updated first, so
* the picker shows them in that order without sorting.
*/
async loadConversations() {
try {
this.conversations = await this.api.listConversations();
} catch (error) {
this.ui.renderError(`Failed to list conversations: ${error.message}`);
this.ui.closeConversations();
return;
}
this.ui.renderConversationList(this.conversations, {
currentId: this.conversationId,
});
}
/**
* filterConversations renders the conversations whose label
* matches the search text. The label is the title, or the
* creation date when the conversation has none.
* @param {string} query
*/
filterConversations(query) {
const needle = query.trim().toLowerCase();
const rows = needle
? this.conversations.filter((conversation) =>
conversationLabel(conversation).toLowerCase().includes(needle),
)
: this.conversations;
this.ui.renderConversationList(rows, {
currentId: this.conversationId,
});
}
/**
* switchConversation selects another conversation. The current
* one stays on the server; only the local view changes.
*
* The target is read first, so a conversation that another client
* deleted does not replace the one in view.
*
* A run in progress is detached, not stopped: the server keeps an
* accepted inference running after the client disconnects, and a
* later switch back re-attaches to it and replays what it saved.
*
* @param {string} id
*/
async switchConversation(id) {
this.ui.closeConversations();
if (id === this.conversationId) return;
try {
await this.api.getConversation(id);
} catch (error) {
this.ui.renderError(`Failed to open conversation: ${error.message}`);
return;
}
this.tts.stop();
// End delivery of the current streams. An accepted inference
// keeps running on the server; see the comment above.
this.syncController?.abort();
if (this.busy) {
this.runController?.abort();
if (this.currentRun) {
try {
await this.currentRun;
} catch { }
}
}
// Drop the local state of the old selection. resync reads and
// renders the whole history of the new selection and caches it.
this.messages.clear();
this.clearDrafts();
this.totalTokens = 0;
this.lastVoice = '';
this.storage.clearConversation();
this.ui.setAutoScroll(false);
this.ui.clearChat();
this.ui.setPendingApprovals([]);
this.ui.updateContext(0, null);
this.conversationId = id;
await this.resync();
this.updateSendEnabled();
}
// ====================
// SYNC
// ====================