Add context tracking

This commit is contained in:
dwrz
2026-06-27 12:35:11 +00:00
parent f2edd39160
commit ec9e0a80f8
5 changed files with 110 additions and 6 deletions

View File

@@ -249,10 +249,19 @@ func (m *Message) UnmarshalJSON(data []byte) error {
// Model represents an available model from the API. // Model represents an available model from the API.
type Model struct { type Model struct {
ID string `json:"id"` ID string `json:"id"`
Created int64 `json:"created"` Created int64 `json:"created"`
Object string `json:"object"` Object string `json:"object"`
OwnedBy string `json:"owned_by"` OwnedBy string `json:"owned_by"`
Meta *ModelMeta `json:"meta,omitempty"`
}
// ModelMeta contains optional model metadata from the provider.
// llama.cpp includes n_ctx (configured context size) and
// n_ctx_train (model's native training context).
type ModelMeta struct {
NCtx int `json:"n_ctx,omitempty"`
NCtxTrain int `json:"n_ctx_train,omitempty"`
} }
// ModelsResponse is the response for GET /models. // ModelsResponse is the response for GET /models.

View File

@@ -768,8 +768,9 @@ const (
// Model represents a model in the /v1/models response. // Model represents a model in the /v1/models response.
type Model struct { type Model struct {
Model string `json:"model"` Model string `json:"model"`
Status ModelStatus `json:"status"` Status ModelStatus `json:"status"`
ContextSize *int `json:"context_size,omitempty"`
} }
// Models is the response format for the /v1/models endpoint. // Models is the response format for the /v1/models endpoint.
@@ -828,9 +829,13 @@ func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
} }
available := make(map[string]struct{}, len(models)) available := make(map[string]struct{}, len(models))
contextSizes := make(map[string]int, len(models))
for _, m := range models { for _, m := range models {
baseID, _, _ := strings.Cut(m.ID, ":") baseID, _, _ := strings.Cut(m.ID, ":")
available[baseID] = struct{}{} available[baseID] = struct{}{}
if m.Meta != nil && m.Meta.NCtx > 0 {
contextSizes[baseID] = m.Meta.NCtx
}
} }
var toCheck []string var toCheck []string
@@ -847,6 +852,9 @@ func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
mi := Model{Model: m} mi := Model{Model: m}
if _, ok := available[base]; ok { if _, ok := available[base]; ok {
mi.Status = ModelStatusAvailable mi.Status = ModelStatusAvailable
if cs, ok := contextSizes[base]; ok {
mi.ContextSize = &cs
}
} else { } else {
mi.Status = ModelStatusNotFound mi.Status = ModelStatusNotFound
} }

View File

@@ -991,3 +991,22 @@ body {
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
/* Context usage indicator in toolbar. */
.footer__toolbar-context {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: 0 var(--s-1);
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-text-muted);
white-space: nowrap;
line-height: 1;
}
.footer__toolbar-context--warning {
color: var(--color-yellow);
}
.footer__toolbar-context--critical {
color: var(--color-red);
}

View File

@@ -69,6 +69,10 @@ class Odidere {
this.leafId = null; this.leafId = null;
this.rootId = null; this.rootId = null;
// Context tracking
this.modelContextSizes = new Map(); // model -> context_size
this.totalTokens = 0;
// TTS state // TTS state
this.ttsURL = document.querySelector('meta[name="tts-url"]')?.content || ''; this.ttsURL = document.querySelector('meta[name="tts-url"]')?.content || '';
this.ttsDefaultVoice = this.ttsDefaultVoice =
@@ -99,6 +103,7 @@ class Odidere {
this.$textInput = document.getElementById('text-input'); this.$textInput = document.getElementById('text-input');
this.$voice = document.getElementById('voice'); this.$voice = document.getElementById('voice');
this.$mute = document.getElementById('mute'); this.$mute = document.getElementById('mute');
this.$context = document.getElementById('context');
this.$transcribe = document.getElementById('transcribe'); this.$transcribe = document.getElementById('transcribe');
// Settings modal // Settings modal
@@ -167,6 +172,8 @@ class Odidere {
this.#clearAttachments(); this.#clearAttachments();
this.$textInput.value = ''; this.$textInput.value = '';
this.$textInput.style.height = 'auto'; this.$textInput.style.height = 'auto';
this.totalTokens = 0;
this.#updateContext();
this.#updateSendButtonState(); this.#updateSendButtonState();
} }
@@ -529,6 +536,11 @@ class Odidere {
if ($el) $el.remove(); if ($el) $el.remove();
} }
// Token count is no longer accurate after deletion;
// it will be refreshed on the next LLM response.
this.totalTokens = 0;
this.#updateContext();
this.#saveToStorage(); this.#saveToStorage();
this.#updateSendButtonState(); this.#updateSendButtonState();
} }
@@ -1627,6 +1639,12 @@ class Odidere {
if (eventType === StreamEventDone) { if (eventType === StreamEventDone) {
if (message.role !== 'assistant') continue; if (message.role !== 'assistant') continue;
// Track cumulative token usage.
if (event.usage?.total_tokens) {
this.totalTokens = event.usage.total_tokens;
this.#updateContext();
}
const meta = streamingMeta(event.usage, event.timings); const meta = streamingMeta(event.usage, event.timings);
let finalMessage = message; let finalMessage = message;
let $streaming = null; let $streaming = null;
@@ -1946,6 +1964,7 @@ class Odidere {
*/ */
#populateModels(providers, defaultProvider, defaultModel) { #populateModels(providers, defaultProvider, defaultModel) {
this.$model.innerHTML = ''; this.$model.innerHTML = '';
this.modelContextSizes.clear();
// Sort provider names. // Sort provider names.
const sortedProviders = Object.keys(providers).sort(); const sortedProviders = Object.keys(providers).sort();
@@ -1963,6 +1982,9 @@ class Odidere {
$opt.value = m.model; $opt.value = m.model;
$opt.dataset.provider = provider; $opt.dataset.provider = provider;
$opt.dataset.model = m.model; $opt.dataset.model = m.model;
if (m.context_size) {
this.modelContextSizes.set(m.model, m.context_size);
}
switch (m.status) { switch (m.status) {
case 'available': case 'available':
@@ -2040,6 +2062,51 @@ class Odidere {
} }
} }
/**
* #updateContext updates the context usage indicator in the toolbar.
* Shows percentage if context_size is known, token count if only
* usage data is available, or hides the indicator otherwise.
*/
#updateContext() {
if (!this.$context) return;
const model = this.$model?.value;
const contextSize = model ? this.modelContextSizes.get(model) : null;
// Hide if no data.
if (this.totalTokens === 0) {
this.$context.hidden = true;
return;
}
this.$context.hidden = false;
// Remove severity classes.
this.$context.classList.remove(
'footer__toolbar-context--warning',
'footer__toolbar-context--critical',
);
const tokenLabel =
this.totalTokens >= 1000
? `${(this.totalTokens / 1000).toFixed(1)}k tokens`
: `${this.totalTokens} tokens`;
if (contextSize && contextSize > 0) {
const pct = Math.round((this.totalTokens / contextSize) * 100);
this.$context.textContent = `${pct}%`;
this.$context.title = tokenLabel;
if (pct >= 90) {
this.$context.classList.add('footer__toolbar-context--critical');
} else if (pct >= 70) {
this.$context.classList.add('footer__toolbar-context--warning');
}
} else {
// No context_size known; show token count.
this.$context.textContent = tokenLabel;
this.$context.title = '';
}
}
/** /**
* #populateVoiceSelect populates voice selector grouped by language. * #populateVoiceSelect populates voice selector grouped by language.
* Voice IDs follow the pattern: {lang}{gender}_{name} (e.g., "af_bella"). * Voice IDs follow the pattern: {lang}{gender}_{name} (e.g., "af_bella").

View File

@@ -8,6 +8,7 @@
> >
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg> <svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
</button> </button>
<span class="footer__toolbar-context" id="context" hidden></span>
<div class="footer__toolbar-spacer"></div> <div class="footer__toolbar-spacer"></div>
<button <button
type="button" type="button"