Add context tracking
This commit is contained in:
@@ -768,8 +768,9 @@ const (
|
||||
|
||||
// Model represents a model in the /v1/models response.
|
||||
type Model struct {
|
||||
Model string `json:"model"`
|
||||
Status ModelStatus `json:"status"`
|
||||
Model string `json:"model"`
|
||||
Status ModelStatus `json:"status"`
|
||||
ContextSize *int `json:"context_size,omitempty"`
|
||||
}
|
||||
|
||||
// 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))
|
||||
contextSizes := make(map[string]int, len(models))
|
||||
for _, m := range models {
|
||||
baseID, _, _ := strings.Cut(m.ID, ":")
|
||||
available[baseID] = struct{}{}
|
||||
if m.Meta != nil && m.Meta.NCtx > 0 {
|
||||
contextSizes[baseID] = m.Meta.NCtx
|
||||
}
|
||||
}
|
||||
|
||||
var toCheck []string
|
||||
@@ -847,6 +852,9 @@ func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
|
||||
mi := Model{Model: m}
|
||||
if _, ok := available[base]; ok {
|
||||
mi.Status = ModelStatusAvailable
|
||||
if cs, ok := contextSizes[base]; ok {
|
||||
mi.ContextSize = &cs
|
||||
}
|
||||
} else {
|
||||
mi.Status = ModelStatusNotFound
|
||||
}
|
||||
|
||||
@@ -991,3 +991,22 @@ body {
|
||||
font-family: var(--font-mono);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ class Odidere {
|
||||
this.leafId = null;
|
||||
this.rootId = null;
|
||||
|
||||
// Context tracking
|
||||
this.modelContextSizes = new Map(); // model -> context_size
|
||||
this.totalTokens = 0;
|
||||
|
||||
// TTS state
|
||||
this.ttsURL = document.querySelector('meta[name="tts-url"]')?.content || '';
|
||||
this.ttsDefaultVoice =
|
||||
@@ -99,6 +103,7 @@ class Odidere {
|
||||
this.$textInput = document.getElementById('text-input');
|
||||
this.$voice = document.getElementById('voice');
|
||||
this.$mute = document.getElementById('mute');
|
||||
this.$context = document.getElementById('context');
|
||||
this.$transcribe = document.getElementById('transcribe');
|
||||
|
||||
// Settings modal
|
||||
@@ -167,6 +172,8 @@ class Odidere {
|
||||
this.#clearAttachments();
|
||||
this.$textInput.value = '';
|
||||
this.$textInput.style.height = 'auto';
|
||||
this.totalTokens = 0;
|
||||
this.#updateContext();
|
||||
this.#updateSendButtonState();
|
||||
}
|
||||
|
||||
@@ -529,6 +536,11 @@ class Odidere {
|
||||
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.#updateSendButtonState();
|
||||
}
|
||||
@@ -1627,6 +1639,12 @@ class Odidere {
|
||||
if (eventType === StreamEventDone) {
|
||||
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);
|
||||
let finalMessage = message;
|
||||
let $streaming = null;
|
||||
@@ -1946,6 +1964,7 @@ class Odidere {
|
||||
*/
|
||||
#populateModels(providers, defaultProvider, defaultModel) {
|
||||
this.$model.innerHTML = '';
|
||||
this.modelContextSizes.clear();
|
||||
|
||||
// Sort provider names.
|
||||
const sortedProviders = Object.keys(providers).sort();
|
||||
@@ -1963,6 +1982,9 @@ class Odidere {
|
||||
$opt.value = m.model;
|
||||
$opt.dataset.provider = provider;
|
||||
$opt.dataset.model = m.model;
|
||||
if (m.context_size) {
|
||||
this.modelContextSizes.set(m.model, m.context_size);
|
||||
}
|
||||
|
||||
switch (m.status) {
|
||||
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.
|
||||
* Voice IDs follow the pattern: {lang}{gender}_{name} (e.g., "af_bella").
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
>
|
||||
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
|
||||
</button>
|
||||
<span class="footer__toolbar-context" id="context" hidden></span>
|
||||
<div class="footer__toolbar-spacer"></div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user