Compare commits
7
Commits
a4cc2d31ba
...
trunk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9ed3f949
|
||
|
|
08ddfbd890
|
||
|
|
866c6585c7
|
||
|
|
caaacf1c98
|
||
|
|
e13d9d849d
|
||
|
|
7b7ac6c7ce
|
||
|
|
7224a2e573
|
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.3/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -155,6 +155,7 @@ type EventType string
|
||||
const (
|
||||
EventAssistantUpdate EventType = "assistant_update"
|
||||
EventDone EventType = "done"
|
||||
EventInferenceActive EventType = "inference_active"
|
||||
EventMessageDeleted EventType = "message_deleted"
|
||||
EventMessageSaved EventType = "message_saved"
|
||||
)
|
||||
@@ -197,6 +198,22 @@ type Done struct {
|
||||
|
||||
func (Done) Type() EventType { return EventDone }
|
||||
|
||||
// InferenceActive reports that an [Inference] is reserved or in progress.
|
||||
// [Conversation.Subscribe] sends this event one time, after the cache diff
|
||||
// and before the live events of the run.
|
||||
//
|
||||
// Without this event, two cases look the same to the client:
|
||||
// - No run is active. The stream closes after the diff.
|
||||
// - A run is active, but it sends no events yet. For example, the model
|
||||
// has not sent its first token, or a slow tool is running.
|
||||
//
|
||||
// A client that did not start the run uses this event to show the stop
|
||||
// control. The client also uses this event to separate saved history
|
||||
// (before the event) from live events (after the event).
|
||||
type InferenceActive struct{}
|
||||
|
||||
func (InferenceActive) Type() EventType { return EventInferenceActive }
|
||||
|
||||
// Inference is one reserved inference call and its events.
|
||||
// [Conversation.Reserve] creates it, and [Inference.Run] runs it.
|
||||
// [Inference.Events] and [Conversation.Subscribe] use its events to send
|
||||
@@ -323,11 +340,11 @@ func (inf *Inference) follow(
|
||||
// 1. Cache diff: [MessageSaved] for each saved message missing from or
|
||||
// newer than manifest, followed by [MessageDeleted] for each ID in
|
||||
// manifest that is no longer saved.
|
||||
// 2. Active inference: if an [Inference] is reserved or running when
|
||||
// Subscribe loads the database, Subscribe streams its
|
||||
// [MessageSaved] and [AssistantUpdate] events, then ends with [Done].
|
||||
// If no inference is reserved or running, Subscribe sends no [Done]
|
||||
// event.
|
||||
// 2. Active inference: if an [Inference] is reserved or in progress when
|
||||
// this method reads the database, the stream sends [InferenceActive],
|
||||
// then the [MessageSaved] and [AssistantUpdate] events of the run, and
|
||||
// then [Done]. If no run is active, the stream closes after the diff and
|
||||
// does not send [InferenceActive] or [Done].
|
||||
//
|
||||
// The channel closes when delivery finishes or ctx is canceled. Canceling
|
||||
// ctx stops delivery to this subscriber; it does not stop an ongoing
|
||||
@@ -372,6 +389,9 @@ func (c *Conversation) Subscribe(
|
||||
if inf == nil {
|
||||
return
|
||||
}
|
||||
if !send(ctx, ch, InferenceActive{}) {
|
||||
return
|
||||
}
|
||||
// Skip events for messages that the initial diff already
|
||||
// sent. If the client's manifest already had a message from
|
||||
// this call, it is not in sent and will be re-sent here; the
|
||||
|
||||
@@ -1474,17 +1474,21 @@ func TestSubscribeInference(t *testing.T) {
|
||||
close(release)
|
||||
got := collectEvents(t, ch)
|
||||
|
||||
// The diff delivers the user message first.
|
||||
// The cache diff sends the user message first. Then, the active marker
|
||||
// shows that the live events of the run start.
|
||||
saved, ok := got[0].(MessageSaved)
|
||||
if !ok || saved.Message.Role != db.MessageRoleUser {
|
||||
t.Fatalf("first event = %v, want the user MessageSaved", got[0])
|
||||
}
|
||||
if _, ok := got[1].(InferenceActive); !ok {
|
||||
t.Fatalf("second event = %v, want InferenceActive", got[1])
|
||||
}
|
||||
var (
|
||||
chunkTexts []string
|
||||
chunkIDs []string
|
||||
asst *MessageSaved
|
||||
)
|
||||
for _, e := range got[1:] {
|
||||
for _, e := range got[2:] {
|
||||
switch e := e.(type) {
|
||||
case AssistantUpdate:
|
||||
if asst != nil {
|
||||
@@ -1589,6 +1593,10 @@ func TestSubscribeInferenceError(t *testing.T) {
|
||||
close(release)
|
||||
got := collectEvents(t, ch)
|
||||
|
||||
// The active marker comes after the user message from the cache diff.
|
||||
if _, ok := got[1].(InferenceActive); !ok {
|
||||
t.Fatalf("second event = %v, want InferenceActive", got[1])
|
||||
}
|
||||
done, ok := got[len(got)-1].(Done)
|
||||
if !ok {
|
||||
t.Fatalf("last event = %v, want Done", got[len(got)-1])
|
||||
@@ -1709,12 +1717,18 @@ func TestSubscribeToolResult(t *testing.T) {
|
||||
t.Errorf("event %d role = %q, want the user first", i, e.Message.Role)
|
||||
}
|
||||
}
|
||||
case InferenceActive:
|
||||
// This event separates the cache diff from the live events
|
||||
// of the run.
|
||||
case Done:
|
||||
if e.Error != "" {
|
||||
t.Errorf("Done.Error = %q, want empty", e.Error)
|
||||
}
|
||||
default:
|
||||
t.Errorf("event %d = %v, want MessageSaved or Done", i, e)
|
||||
t.Errorf(
|
||||
"event %d = %v, want MessageSaved, InferenceActive, or Done",
|
||||
i, e,
|
||||
)
|
||||
}
|
||||
}
|
||||
if aId == "" {
|
||||
|
||||
@@ -86,8 +86,7 @@ body {
|
||||
height: 100dvh;
|
||||
max-width: calc(var(--measure) + var(--s3) * 2);
|
||||
margin-inline: auto;
|
||||
padding: var(--s0);
|
||||
padding-bottom: 0;
|
||||
padding: 0 var(--s0);
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
@@ -95,14 +94,15 @@ body {
|
||||
/* ==================== */
|
||||
|
||||
.chat {
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
border-top: none;
|
||||
border: 1px solid var(--color-border-light);
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--s1);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.chat > * + * {
|
||||
@@ -253,21 +253,6 @@ body {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.compose__model {
|
||||
padding: 0 var(--s-1);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.compose__model:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compose__attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -352,10 +337,25 @@ body {
|
||||
/* ==================== */
|
||||
|
||||
.footer {
|
||||
margin-top: var(--s0);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.footer__status {
|
||||
padding: var(--s-2) var(--s-1);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
font-style: italic;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer__status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer__select {
|
||||
height: 2rem;
|
||||
padding: 0 var(--s-1);
|
||||
@@ -376,15 +376,14 @@ body {
|
||||
}
|
||||
|
||||
.footer__toolbar {
|
||||
border-inline: none;
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius);
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
|
||||
.footer__toolbar-btn {
|
||||
@@ -863,16 +862,15 @@ body {
|
||||
@media (max-width: 639px) {
|
||||
.container {
|
||||
padding: 0;
|
||||
padding-top: var(--s-1);
|
||||
}
|
||||
|
||||
.chat {
|
||||
border-radius: 0;
|
||||
border-inline: none;
|
||||
padding-inline: var(--s0);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--s-1);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,10 @@ export class Api {
|
||||
* message_deleted per manifest ID that is no longer saved. An
|
||||
* empty manifest requests every saved row.
|
||||
*
|
||||
* When an inference is reserved or ongoing, the stream then follows
|
||||
* its events and ends with done. When none is, the stream ends
|
||||
* after the diff without done.
|
||||
* If an inference run is reserved or in progress, the stream sends one
|
||||
* inference_active event, then the live events of the run, and then done.
|
||||
* If no run is active, the stream closes after the diff and does not send
|
||||
* inference_active or done.
|
||||
*
|
||||
* @param {string} id - conversation ID.
|
||||
* @param {Array<{id: string, updated_at: string}>} manifest
|
||||
@@ -345,7 +346,10 @@ export class Api {
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
yield* readEvents(res);
|
||||
for await (const event of readEvents(res)) {
|
||||
if (signal?.aborted) return;
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+183
-156
@@ -43,25 +43,38 @@ class Odidere {
|
||||
document,
|
||||
handlers: {
|
||||
onComposeInput: () => this.updateSendEnabled(),
|
||||
onComposeSettings: () =>
|
||||
onComposeSettings: () => {
|
||||
const settings = this.resolveConversationSettings();
|
||||
this.ui.openComposeSettings({
|
||||
maxIterations: this.storage.getMaxIterations(),
|
||||
reasoningBudget: this.storage.getReasoningBudget(),
|
||||
}),
|
||||
maxIterations: settings.maxIterations,
|
||||
reasoningBudget: settings.reasoningBudget,
|
||||
});
|
||||
},
|
||||
onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => {
|
||||
this.storage.setMaxIterations(maxIterations);
|
||||
this.storage.setReasoningBudget(
|
||||
Number.isFinite(reasoningBudget) ? reasoningBudget : null,
|
||||
);
|
||||
if (!this.conversationId) {
|
||||
this.storage.setMaxIterations(maxIterations);
|
||||
this.storage.setReasoningBudget(reasoningBudget);
|
||||
return;
|
||||
}
|
||||
const { provider, model } = this.ui.getSelectedModel();
|
||||
this.storage.setConversationSettings(this.conversationId, {
|
||||
provider,
|
||||
model,
|
||||
maxIterations,
|
||||
reasoningBudget,
|
||||
});
|
||||
},
|
||||
onComposeSubmit: () => this.submit(),
|
||||
onDefaultModelChange: ({ provider, model }) =>
|
||||
this.storage.setModelSelection({ provider, model }),
|
||||
onComposeModelChange: ({ provider, model }) => {
|
||||
if (this.conversationId) {
|
||||
this.storage.setConversationModel(this.conversationId, {
|
||||
const settings = this.resolveConversationSettings();
|
||||
this.storage.setConversationSettings(this.conversationId, {
|
||||
provider,
|
||||
model,
|
||||
maxIterations: settings.maxIterations,
|
||||
reasoningBudget: settings.reasoningBudget,
|
||||
});
|
||||
}
|
||||
this.ui.updateContext(
|
||||
@@ -200,28 +213,15 @@ class Odidere {
|
||||
// ====================
|
||||
|
||||
/**
|
||||
* orderedRows returns the cached rows in the order they were
|
||||
* saved. The server sends rows in saved order on every stream,
|
||||
* and the map keeps that order through updates.
|
||||
* orderedRows returns the cached rows in saved order, sorted by
|
||||
* sequence. The cache is a Map keyed by message ID, and a Map
|
||||
* keeps the order in which keys were first added: a row that
|
||||
* another client inserted earlier in the conversation arrives
|
||||
* after rows that follow it. Sorting puts it in its place.
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
orderedRows() {
|
||||
return [...this.messages.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* isOrdered reports whether the cached rows are still in saved
|
||||
* order. A row another client inserted or deleted can shift the
|
||||
* sequences of rows the sync diff does not re-send, because their
|
||||
* updated_at did not change; the check catches that.
|
||||
*/
|
||||
isOrdered() {
|
||||
let previous = -Infinity;
|
||||
for (const row of this.messages.values()) {
|
||||
if (row.sequence <= previous) return false;
|
||||
previous = row.sequence;
|
||||
}
|
||||
return true;
|
||||
return [...this.messages.values()].sort((a, b) => a.sequence - b.sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,31 +329,36 @@ class Odidere {
|
||||
* @param {string} type
|
||||
* @param {Object} data
|
||||
* @param {string} [voice]
|
||||
* @param {boolean} [speak] - false while reading the saved diff,
|
||||
* so history is not read aloud.
|
||||
* @returns {boolean} true when the stream is finished.
|
||||
*/
|
||||
handleEvent(type, data, voice = '') {
|
||||
handleEvent(type, data, voice = '', speak = true) {
|
||||
switch (type) {
|
||||
case 'message_saved': {
|
||||
const row = data.message;
|
||||
const existed = this.messages.has(row.id);
|
||||
const previous = this.messages.get(row.id);
|
||||
// When a subscriber joins a run, the server resends the
|
||||
// run's events for messages the client's manifest already
|
||||
// held (see Subscribe in conversation.go). Skip a revision
|
||||
// that is already cached, so it does not render or speak
|
||||
// again.
|
||||
if (previous?.updated_at === row.updated_at) return false;
|
||||
const existed = previous !== undefined;
|
||||
this.cacheRow(row, voice);
|
||||
this.applySavedRow(row, existed, voice);
|
||||
this.applySavedRow(row, existed, voice, speak);
|
||||
this.trackUsage(row.metadata?.usage);
|
||||
return false;
|
||||
}
|
||||
case 'message_deleted': {
|
||||
const row = this.messages.get(data.message_id);
|
||||
this.messages.delete(data.message_id);
|
||||
const existed = this.messages.delete(data.message_id);
|
||||
this.persistCache();
|
||||
if (!row) return false;
|
||||
if (row.role === 'tool') {
|
||||
this.ui.clearToolOutput(row);
|
||||
} else {
|
||||
this.ui.removeMessageBubbles(data.message_id);
|
||||
}
|
||||
if (existed) this.ui.renderConversation(this.orderedRows());
|
||||
return false;
|
||||
}
|
||||
case 'assistant_update': {
|
||||
// Skip resent chunks of a message that is already saved.
|
||||
if (this.messages.has(data.message_id)) return false;
|
||||
const draft = this.applyChunk(data.message_id, data.chunk);
|
||||
this.ui.renderDraft(draft);
|
||||
this.trackUsage(data.chunk?.usage);
|
||||
@@ -376,8 +381,9 @@ class Odidere {
|
||||
* @param {Object} row
|
||||
* @param {boolean} existed - whether the row was already cached.
|
||||
* @param {string} voice - the voice of this run, '' when unknown.
|
||||
* @param {boolean} [speak] - whether to read the reply aloud.
|
||||
*/
|
||||
applySavedRow(row, existed, voice = '') {
|
||||
applySavedRow(row, existed, voice = '', speak = true) {
|
||||
const wasDraft = this.drafts.delete(row.id);
|
||||
if (row.role === 'tool') {
|
||||
this.ui.attachToolResult(
|
||||
@@ -387,12 +393,11 @@ class Odidere {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existed || wasDraft) {
|
||||
if (wasDraft) {
|
||||
this.ui.replaceDraftWithSaved(row.id, row, this.resultsFor(row));
|
||||
} else {
|
||||
this.ui.renderConversation(this.orderedRows());
|
||||
}
|
||||
const rows = this.orderedRows();
|
||||
if (wasDraft) {
|
||||
this.ui.replaceDraftWithSaved(row.id, row, this.resultsFor(row));
|
||||
} else if (existed || rows.at(-1)?.id !== row.id) {
|
||||
this.ui.renderConversation(rows);
|
||||
} else {
|
||||
this.ui.renderMessages([row, ...this.resultsFor(row)]);
|
||||
if (row.role === 'user') {
|
||||
@@ -405,7 +410,7 @@ class Odidere {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (row.role === 'assistant') {
|
||||
if (speak && row.role === 'assistant') {
|
||||
const text = extractText(row.content?.content);
|
||||
const cached = this.messages.get(row.id);
|
||||
if (text) {
|
||||
@@ -469,6 +474,25 @@ class Odidere {
|
||||
this.drafts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* setBusy marks the conversation busy while a run or an attached
|
||||
* subscription streams, or clears that state and discards unsaved
|
||||
* drafts when the stream ends.
|
||||
* @param {boolean} busy
|
||||
*/
|
||||
setBusy(busy) {
|
||||
this.busy = busy;
|
||||
this.ui.setBusy(busy);
|
||||
if (busy) {
|
||||
this.ui.setPendingApprovals([]);
|
||||
this.wakeLock.acquire();
|
||||
} else {
|
||||
this.clearDrafts();
|
||||
this.updateSendEnabled();
|
||||
this.wakeLock.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* trackUsage keeps the newest reported token total. Streaming
|
||||
* usage reports replace earlier ones; they are not increments.
|
||||
@@ -568,50 +592,51 @@ class Odidere {
|
||||
|
||||
const controller = new AbortController();
|
||||
this.runController = controller;
|
||||
this.busy = true;
|
||||
this.ui.setBusy(true);
|
||||
this.ui.setPendingApprovals([]);
|
||||
// Keep the screen on while the run streams.
|
||||
this.wakeLock.acquire();
|
||||
this.setBusy(true);
|
||||
|
||||
// Consume the input once the server accepted the request: an
|
||||
// earlier failure keeps it for a retry.
|
||||
let consumed = false;
|
||||
const consumeInput = () => {
|
||||
if (consumed) return;
|
||||
consumed = true;
|
||||
this.attachments = [];
|
||||
this.locationData = null;
|
||||
this.renderAttachments();
|
||||
this.ui.setLocationActive(false);
|
||||
this.updateSendEnabled();
|
||||
};
|
||||
|
||||
try {
|
||||
const body = { stream: true };
|
||||
if (message) body.message = message;
|
||||
// Read the selected model once. Save it for this conversation,
|
||||
// which always has an ID here, and send it with the request.
|
||||
// Read the conversation's settings once: the stored entry, or
|
||||
// the defaults when the conversation has none. Save them for
|
||||
// this conversation, which always has an ID here, and send them
|
||||
// with the request.
|
||||
const settings = this.resolveConversationSettings();
|
||||
const { provider, model } = this.ui.getSelectedModel();
|
||||
if (provider && model) {
|
||||
this.storage.setConversationModel(this.conversationId, {
|
||||
this.storage.setConversationSettings(this.conversationId, {
|
||||
provider,
|
||||
model,
|
||||
maxIterations: settings.maxIterations,
|
||||
reasoningBudget: settings.reasoningBudget,
|
||||
});
|
||||
}
|
||||
if (provider) body.provider = provider;
|
||||
if (model) body.model = model;
|
||||
const maxIterations = this.storage.getMaxIterations();
|
||||
if (maxIterations > 0) body.max_iterations = maxIterations;
|
||||
const budget = this.storage.getReasoningBudget();
|
||||
if (budget !== null) body.thinking_budget_tokens = budget;
|
||||
if (settings.maxIterations > 0) {
|
||||
body.max_iterations = settings.maxIterations;
|
||||
}
|
||||
if (settings.reasoningBudget !== null) {
|
||||
body.thinking_budget_tokens = settings.reasoningBudget;
|
||||
}
|
||||
|
||||
for await (const { type, data } of this.api.inference(
|
||||
this.conversationId,
|
||||
body,
|
||||
controller.signal,
|
||||
)) {
|
||||
consumeInput();
|
||||
if (!consumed) {
|
||||
consumed = true;
|
||||
this.attachments = [];
|
||||
this.locationData = null;
|
||||
this.renderAttachments();
|
||||
this.ui.setLocationActive(false);
|
||||
this.updateSendEnabled();
|
||||
}
|
||||
if (this.handleEvent(type, data, voice)) break;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -625,17 +650,19 @@ class Odidere {
|
||||
} else if (error.name !== 'AbortError') {
|
||||
console.error('inference failed:', error);
|
||||
this.ui.renderError(`Inference failed: ${error.message}`);
|
||||
this.ui.appendComposeText(text);
|
||||
// Put the text back into the compose field only if the
|
||||
// request failed before the server accepted it. If the
|
||||
// stream disconnects later, the server already has the
|
||||
// message and continues the run in the background.
|
||||
if (!consumed) this.ui.appendComposeText(text);
|
||||
}
|
||||
// An AbortError ends delivery only; reset deleted the
|
||||
// conversation and will clear the local state.
|
||||
} finally {
|
||||
if (this.runController === controller) this.runController = null;
|
||||
this.clearDrafts();
|
||||
this.busy = false;
|
||||
this.ui.setBusy(false);
|
||||
this.updateSendEnabled();
|
||||
this.wakeLock.release();
|
||||
if (this.runController === controller) {
|
||||
this.runController = null;
|
||||
this.setBusy(false);
|
||||
}
|
||||
if (this.conversationId && !controller.signal.aborted) {
|
||||
await this.sync();
|
||||
}
|
||||
@@ -722,7 +749,7 @@ class Odidere {
|
||||
if (id) {
|
||||
try {
|
||||
await this.api.deleteConversation(id);
|
||||
this.storage.deleteConversationModel(id);
|
||||
this.storage.deleteConversationSettings(id);
|
||||
} catch (error) {
|
||||
// The conversation survives on the server, but the local
|
||||
// selection is still cleared.
|
||||
@@ -748,7 +775,7 @@ class Odidere {
|
||||
this.ui.clearChat();
|
||||
this.ui.setPendingApprovals([]);
|
||||
this.ui.renderSystemDefault(this.storage.getSystemMessage());
|
||||
this.ui.selectComposeModel(this.resolveConversationModel(null));
|
||||
this.ui.selectComposeModel(this.resolveConversationSettings(null));
|
||||
this.ui.updateContext(0, null);
|
||||
this.updateSendEnabled();
|
||||
}
|
||||
@@ -776,6 +803,7 @@ class Odidere {
|
||||
await this.currentRun;
|
||||
} catch {}
|
||||
}
|
||||
if (this.busy) this.setBusy(false);
|
||||
}
|
||||
|
||||
// ====================
|
||||
@@ -795,9 +823,9 @@ class Odidere {
|
||||
this.ui.closeConversations();
|
||||
return;
|
||||
}
|
||||
// Remove model entries for conversations deleted in other tabs
|
||||
// Remove settings entries for conversations deleted in other tabs
|
||||
// or on other devices.
|
||||
this.storage.pruneConversationModels(this.conversations.map((c) => c.id));
|
||||
this.storage.pruneConversationSettings(this.conversations.map((c) => c.id));
|
||||
this.ui.renderConversationList(this.conversations, {
|
||||
currentId: this.conversationId,
|
||||
});
|
||||
@@ -856,9 +884,10 @@ class Odidere {
|
||||
await this.currentRun;
|
||||
} catch {}
|
||||
}
|
||||
if (this.busy) this.setBusy(false);
|
||||
}
|
||||
|
||||
// Drop the local state of the old selection. resync reads and
|
||||
// Drop the local state of the old selection. sync reads and
|
||||
// renders the whole history of the new selection and caches it.
|
||||
this.messages.clear();
|
||||
this.clearDrafts();
|
||||
@@ -870,8 +899,8 @@ class Odidere {
|
||||
this.ui.setPendingApprovals([]);
|
||||
this.ui.updateContext(0, null);
|
||||
this.conversationId = id;
|
||||
this.ui.selectComposeModel(this.resolveConversationModel(id));
|
||||
await this.resync();
|
||||
this.ui.selectComposeModel(this.resolveConversationSettings(id));
|
||||
await this.sync();
|
||||
this.updateSendEnabled();
|
||||
}
|
||||
|
||||
@@ -911,7 +940,7 @@ class Odidere {
|
||||
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
this.storage.deleteConversationModel(id);
|
||||
this.storage.deleteConversationSettings(id);
|
||||
this.conversations = this.conversations.filter((c) => c.id !== id);
|
||||
if (id === this.conversationId) {
|
||||
// The server-side delete cancels an ongoing inference.
|
||||
@@ -943,19 +972,21 @@ class Odidere {
|
||||
|
||||
/**
|
||||
* sync subscribes with the cached rows as its manifest. The
|
||||
* server sends what changed since they were saved, then the
|
||||
* events of a run in progress, ending with done. Without a run
|
||||
* in progress, the stream ends after the diff: the cache is then
|
||||
* in step with the saved history until the next change.
|
||||
* server sends what changed since they were saved, then, when a
|
||||
* run is in progress, inference_active and the run's events,
|
||||
* ending with done. Without a run in progress, the stream ends
|
||||
* after the diff: the cache is then in step with the saved
|
||||
* history until the next change.
|
||||
*
|
||||
* Re-subscribing is the reconnect path: it replays what a lost
|
||||
* stream missed and re-attaches to a run still in progress.
|
||||
*/
|
||||
async sync() {
|
||||
if (!this.conversationId || this.busy) return;
|
||||
if (!this.conversationId || this.runController) return;
|
||||
const controller = new AbortController();
|
||||
this.syncController?.abort();
|
||||
this.syncController = controller;
|
||||
let live = false;
|
||||
try {
|
||||
const manifest = this.orderedRows().map(({ id, updated_at }) => ({
|
||||
id,
|
||||
@@ -966,9 +997,13 @@ class Odidere {
|
||||
manifest,
|
||||
controller.signal,
|
||||
)) {
|
||||
if (this.handleEvent(type, data)) break;
|
||||
if (type === 'inference_active') {
|
||||
live = true;
|
||||
this.setBusy(true);
|
||||
} else if (this.handleEvent(type, data, '', live)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!this.isOrdered()) await this.resync();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
this.ui.renderError('This conversation was deleted.');
|
||||
@@ -980,48 +1015,12 @@ class Odidere {
|
||||
console.error('sync failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (this.syncController === controller) this.syncController = null;
|
||||
}
|
||||
if (this.conversationId && !this.busy) {
|
||||
await this.updateApprovals();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* resync replaces the whole cache from the saved history. A
|
||||
* structural change can shift rows the sync diff does not
|
||||
* re-send, because their updated_at did not change; a full read
|
||||
* restores the saved order.
|
||||
*/
|
||||
async resync() {
|
||||
if (!this.conversationId || this.busy) return;
|
||||
const controller = new AbortController();
|
||||
this.syncController?.abort();
|
||||
this.syncController = controller;
|
||||
this.messages.clear();
|
||||
this.drafts.clear();
|
||||
try {
|
||||
for await (const { type, data } of this.api.subscribe(
|
||||
this.conversationId,
|
||||
[],
|
||||
controller.signal,
|
||||
)) {
|
||||
if (type === 'message_saved') {
|
||||
this.cacheRow(data.message, '', false);
|
||||
} else if (this.handleEvent(type, data)) {
|
||||
break;
|
||||
}
|
||||
if (this.syncController === controller) {
|
||||
this.syncController = null;
|
||||
if (live) this.setBusy(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('resync failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (this.syncController === controller) this.syncController = null;
|
||||
this.persistCache();
|
||||
}
|
||||
this.ui.renderConversation(this.orderedRows());
|
||||
if (this.conversationId && !this.busy) {
|
||||
if (!controller.signal.aborted && this.conversationId && !this.busy) {
|
||||
await this.updateApprovals();
|
||||
}
|
||||
}
|
||||
@@ -1032,7 +1031,7 @@ class Odidere {
|
||||
*/
|
||||
async clearSelection() {
|
||||
if (this.conversationId) {
|
||||
this.storage.deleteConversationModel(this.conversationId);
|
||||
this.storage.deleteConversationSettings(this.conversationId);
|
||||
}
|
||||
this.messages.clear();
|
||||
this.drafts.clear();
|
||||
@@ -1043,7 +1042,7 @@ class Odidere {
|
||||
this.ui.clearChat();
|
||||
this.ui.setPendingApprovals([]);
|
||||
this.ui.renderSystemDefault(this.storage.getSystemMessage());
|
||||
this.ui.selectComposeModel(this.resolveConversationModel(null));
|
||||
this.ui.selectComposeModel(this.resolveConversationSettings(null));
|
||||
this.ui.updateContext(0, null);
|
||||
this.updateSendEnabled();
|
||||
}
|
||||
@@ -1102,8 +1101,7 @@ class Odidere {
|
||||
* so the conversation can continue. The decision alone would
|
||||
* leave the call without a result, and every later run would
|
||||
* refuse the batch with 'will not retry'. The note inserts a
|
||||
* message, so the cache is replaced from the saved order before
|
||||
* the run continues.
|
||||
* message, so sync updates the cache before the run continues.
|
||||
* @param {{executionId: string, messageId: string, toolCallId: string}} execution
|
||||
*/
|
||||
async deny({ executionId, messageId, toolCallId }) {
|
||||
@@ -1137,7 +1135,7 @@ class Odidere {
|
||||
this.reportMutationError(error);
|
||||
return;
|
||||
}
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
await this.send({ text: '', voice: this.lastVoice });
|
||||
}
|
||||
|
||||
@@ -1261,8 +1259,8 @@ class Odidere {
|
||||
}
|
||||
|
||||
if (!resultRow && edited.result) {
|
||||
// A new result is an inserted message; replace the cache
|
||||
// from the saved order before the display renders it.
|
||||
// A new result is an inserted message; sync brings in the
|
||||
// result and any shifted sequences.
|
||||
try {
|
||||
await this.api.insertMessage(this.conversationId, {
|
||||
message: {
|
||||
@@ -1277,7 +1275,7 @@ class Odidere {
|
||||
this.reportMutationError(error);
|
||||
return;
|
||||
}
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1323,7 +1321,7 @@ class Odidere {
|
||||
if (field === 'tool' && toolCallId) {
|
||||
// The server removes the call, its result, and its
|
||||
// execution record together, and rewrites the assistant row;
|
||||
// replace the cache from the saved order.
|
||||
// sync applies those changes.
|
||||
try {
|
||||
await this.api.deleteToolCall(
|
||||
this.conversationId,
|
||||
@@ -1335,7 +1333,7 @@ class Odidere {
|
||||
this.reportMutationError(error);
|
||||
return;
|
||||
}
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1354,7 +1352,7 @@ class Odidere {
|
||||
this.reportMutationError(error);
|
||||
return;
|
||||
}
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1365,13 +1363,13 @@ class Odidere {
|
||||
this.reportMutationError(error);
|
||||
return;
|
||||
}
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* putMessage updates one saved message and re-renders the chat
|
||||
* from the cache. An update does not shift rows, so no resync is
|
||||
* needed.
|
||||
* from the cache. The response returns the updated row, so no
|
||||
* sync is needed.
|
||||
* @param {string} mid
|
||||
* @param {Object} body - message and updated_at.
|
||||
*/
|
||||
@@ -1399,10 +1397,10 @@ class Odidere {
|
||||
async reportMutationError(error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
this.ui.renderError('The message changed elsewhere. Reloading it.');
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
} else if (error instanceof ApiError && error.status === 404) {
|
||||
this.ui.renderError('The message is gone. Reloading the conversation.');
|
||||
await this.resync();
|
||||
await this.sync();
|
||||
} else {
|
||||
this.ui.renderError(`Edit failed: ${error.message}`);
|
||||
}
|
||||
@@ -1594,18 +1592,47 @@ class Odidere {
|
||||
// ====================
|
||||
|
||||
/**
|
||||
* resolveConversationModel returns the provider and model stored
|
||||
* for the given conversation ID, falling back to the default
|
||||
* model selection when none is stored.
|
||||
* resolveConversationSettings returns the settings stored for the
|
||||
* given conversation ID, falling back to the default selections
|
||||
* when none are stored. Invalid numbers never reach a request:
|
||||
* iterations that are not a positive safe integer read as 0, and
|
||||
* a budget that is not a safe integer reads as null. A budget
|
||||
* below -1 reads as -1.
|
||||
* @param {string | null} [id]
|
||||
* @returns {{provider: string | null, model: string | null}}
|
||||
* @returns {{provider: string | null, model: string | null, maxIterations: number, reasoningBudget: number | null}}
|
||||
*/
|
||||
resolveConversationModel(id = this.conversationId) {
|
||||
resolveConversationSettings(id = this.conversationId) {
|
||||
if (id) {
|
||||
const stored = this.storage.getConversationModel(id);
|
||||
if (stored.provider && stored.model) return stored;
|
||||
const stored = this.storage.getConversationSettings(id);
|
||||
if (stored) {
|
||||
return {
|
||||
provider: stored.provider,
|
||||
model: stored.model,
|
||||
maxIterations:
|
||||
Number.isSafeInteger(stored.maxIterations) &&
|
||||
stored.maxIterations > 0
|
||||
? stored.maxIterations
|
||||
: 0,
|
||||
reasoningBudget: Number.isSafeInteger(stored.reasoningBudget)
|
||||
? Math.max(stored.reasoningBudget, -1)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return this.storage.getModelSelection();
|
||||
const { provider, model } = this.storage.getModelSelection();
|
||||
const maxIterations = this.storage.getMaxIterations();
|
||||
const budget = this.storage.getReasoningBudget();
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
maxIterations:
|
||||
Number.isSafeInteger(maxIterations) && maxIterations > 0
|
||||
? maxIterations
|
||||
: 0,
|
||||
reasoningBudget: Number.isSafeInteger(budget)
|
||||
? Math.max(budget, -1)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async loadModels() {
|
||||
@@ -1614,7 +1641,7 @@ class Odidere {
|
||||
this.ui.populateModels(
|
||||
data,
|
||||
this.storage.getModelSelection(),
|
||||
this.resolveConversationModel(),
|
||||
this.resolveConversationSettings(),
|
||||
);
|
||||
this.ui.updateContext(
|
||||
this.totalTokens,
|
||||
|
||||
@@ -12,17 +12,16 @@
|
||||
* conversation is created. It does not change a conversation that
|
||||
* already exists.
|
||||
* - odidere_provider, odidere_model, odidere_voice: default selections.
|
||||
* - odidere_conversation_models: per-conversation provider and model
|
||||
* selections, keyed by conversation ID.
|
||||
* - odidere_max_turns, odidere_reasoning_budget: compose settings.
|
||||
* - odidere_conversation_settings: per-conversation settings, keyed
|
||||
* by conversation ID. Each entry is complete: it holds the
|
||||
* provider, model, max iterations, and reasoning budget together.
|
||||
* - odidere_max_turns, odidere_reasoning_budget: default compose
|
||||
* settings, used when a conversation has no stored entry.
|
||||
* - odidere_mute: whether speech playback is muted.
|
||||
*
|
||||
* The browser-only history that older versions kept under
|
||||
* odidere_history is not read and is left untouched.
|
||||
*/
|
||||
|
||||
const CONVERSATION_KEY = 'odidere_conversation';
|
||||
const CONVERSATION_MODELS_KEY = 'odidere_conversation_models';
|
||||
const CONVERSATION_SETTINGS_KEY = 'odidere_conversation_settings';
|
||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||
const PROVIDER_KEY = 'odidere_provider';
|
||||
const MODEL_KEY = 'odidere_model';
|
||||
@@ -112,88 +111,91 @@ export class Storage {
|
||||
}
|
||||
|
||||
/**
|
||||
* _loadConversationModels reads the per-conversation model map.
|
||||
* @returns {Object<string, {provider: string, model: string}>}
|
||||
* _loadConversationSettings reads the per-conversation settings map.
|
||||
* @returns {Object<string, {provider: string, model: string, maxIterations: number, reasoningBudget: number | null}>}
|
||||
* @private
|
||||
*/
|
||||
_loadConversationModels() {
|
||||
_loadConversationSettings() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(CONVERSATION_MODELS_KEY));
|
||||
const parsed = JSON.parse(
|
||||
localStorage.getItem(CONVERSATION_SETTINGS_KEY),
|
||||
);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: {};
|
||||
} catch (error) {
|
||||
console.error('failed to load conversation models:', error);
|
||||
console.error('failed to load conversation settings:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getConversationModel returns the provider and model stored for
|
||||
* one conversation, or null fields when none is stored.
|
||||
* getConversationSettings returns the settings stored for one
|
||||
* conversation, or null when none are stored.
|
||||
* @param {string} id - conversation ID.
|
||||
* @returns {{provider: string | null, model: string | null}}
|
||||
* @returns {{provider: string, model: string, maxIterations: number, reasoningBudget: number | null} | null}
|
||||
*/
|
||||
getConversationModel(id) {
|
||||
const entry = this._loadConversationModels()[id];
|
||||
return {
|
||||
provider: entry?.provider || null,
|
||||
model: entry?.model || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* setConversationModel stores the provider and model for one
|
||||
* conversation.
|
||||
* @param {string} id - conversation ID.
|
||||
* @param {{provider: string, model: string}} selection
|
||||
*/
|
||||
setConversationModel(id, { provider, model }) {
|
||||
if (!id || !provider || !model) return;
|
||||
try {
|
||||
const map = this._loadConversationModels();
|
||||
map[id] = { provider, model };
|
||||
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
console.error('failed to save conversation model:', error);
|
||||
getConversationSettings(id) {
|
||||
const entry = this._loadConversationSettings()[id];
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteConversationModel removes the stored model entry for one
|
||||
* conversation.
|
||||
* setConversationSettings stores the settings for one
|
||||
* conversation. Store the complete settings: a stored entry is
|
||||
* used as stored, and no lookup completes it from the defaults.
|
||||
* @param {string} id - conversation ID.
|
||||
* @param {{provider: string, model: string, maxIterations: number, reasoningBudget: number | null}} settings
|
||||
*/
|
||||
deleteConversationModel(id) {
|
||||
setConversationSettings(id, settings) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const map = this._loadConversationModels();
|
||||
if (!(id in map)) return;
|
||||
delete map[id];
|
||||
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
|
||||
const map = this._loadConversationSettings();
|
||||
map[id] = settings;
|
||||
localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
console.error('failed to delete conversation model:', error);
|
||||
console.error('failed to save conversation settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pruneConversationModels removes stored model entries for
|
||||
* deleteConversationSettings removes the stored settings for one
|
||||
* conversation.
|
||||
* @param {string} id - conversation ID.
|
||||
*/
|
||||
deleteConversationSettings(id) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const map = this._loadConversationSettings();
|
||||
if (!(id in map)) return;
|
||||
delete map[id];
|
||||
localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
console.error('failed to delete conversation settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pruneConversationSettings removes stored settings for
|
||||
* conversations that no longer exist on the server (for example,
|
||||
* conversations deleted from another device or browser tab), so
|
||||
* the localStorage map does not grow without bound.
|
||||
* @param {Iterable<string>} validIds - conversation IDs currently
|
||||
* on the server.
|
||||
*/
|
||||
pruneConversationModels(validIds) {
|
||||
pruneConversationSettings(validIds) {
|
||||
try {
|
||||
const keep = new Set(validIds);
|
||||
const map = this._loadConversationModels();
|
||||
const map = this._loadConversationSettings();
|
||||
for (const id of Object.keys(map)) {
|
||||
if (!keep.has(id)) delete map[id];
|
||||
}
|
||||
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
|
||||
localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
console.error('failed to prune conversation models:', error);
|
||||
console.error('failed to prune conversation settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,11 +217,12 @@ export class Storage {
|
||||
/**
|
||||
* getMaxIterations returns the agent loop limit. Zero means no
|
||||
* limit: the model replies without tool calls or the run fails.
|
||||
* The value is returned as stored and is not checked:
|
||||
* resolveConversationSettings corrects invalid values before use.
|
||||
* @returns {number}
|
||||
*/
|
||||
getMaxIterations() {
|
||||
const stored = Number(localStorage.getItem(MAX_TURNS_KEY));
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 0;
|
||||
return Number(localStorage.getItem(MAX_TURNS_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,14 +236,14 @@ export class Storage {
|
||||
|
||||
/**
|
||||
* getReasoningBudget returns the llama.cpp reasoning token budget,
|
||||
* or null when unset. -1 means unlimited and 0 means off.
|
||||
* or null when unset. -1 means unlimited and 0 means off. The
|
||||
* value is returned as stored and is not checked:
|
||||
* resolveConversationSettings corrects invalid values before use.
|
||||
* @returns {number | null}
|
||||
*/
|
||||
getReasoningBudget() {
|
||||
const stored = localStorage.getItem(REASONING_BUDGET_KEY);
|
||||
if (stored === null) return null;
|
||||
const value = Number(stored);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
return stored === null ? null : Number(stored);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -820,14 +820,15 @@ export class UI {
|
||||
|
||||
/**
|
||||
* _updateComposeModelLabel sets the text and hover title of the
|
||||
* compose bar's model indicator to the active provider/model.
|
||||
* footer status bar's model indicator to the active provider and model.
|
||||
* @private
|
||||
*/
|
||||
_updateComposeModelLabel() {
|
||||
const { provider, model } = this.getSelectedModel();
|
||||
const label = provider && model ? `${provider}/${model}` : '';
|
||||
const label = provider && model ? `${provider} • ${model}` : '';
|
||||
this.$composeModelLabel.textContent = label;
|
||||
this.$composeModelLabel.title = label;
|
||||
this.$composeModelLabel.hidden = !label;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
>
|
||||
<svg class="icon"><use href="/static/icons.svg#location"></use></svg>
|
||||
</button>
|
||||
<span class="compose__model" id="compose-model-label"></span>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple hidden />
|
||||
<div class="compose__actions-right">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{{ define "footer" }}
|
||||
<footer class="footer">
|
||||
<div class="footer__status" id="compose-model-label" hidden></div>
|
||||
{{ template "footer/compose" . }}
|
||||
{{ template "footer/toolbar" . }}
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user