diff --git a/internal/service/service.go b/internal/service/service.go index d199066..851320e 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -446,6 +446,8 @@ type Request struct { Model string `json:"model,omitempty"` // SystemMessage overrides the configured system message for this request. SystemMessage string `json:"system_message,omitempty"` + // MaxIterations caps the number of agent loop turns (0 = unlimited). + MaxIterations int `json:"max_iterations,omitempty"` } // Response is the response format for chat and voice endpoints. @@ -541,7 +543,7 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) { } res, err := llmc.Completions( - ctx, model, req.SystemMessage, 0, req.Messages, + ctx, model, req.SystemMessage, req.MaxIterations, req.Messages, ) if err != nil { log.ErrorContext( @@ -713,7 +715,7 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) { ) go func() { errs <- llmc.CompletionsStream( - ctx, model, req.SystemMessage, 0, req.Messages, events, + ctx, model, req.SystemMessage, req.MaxIterations, req.Messages, events, ) close(errs) }() diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index 94c1a2b..f1bdf43 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -134,4 +134,10 @@ + + + + + + diff --git a/internal/service/static/main.css b/internal/service/static/main.css index 6018b83..dce9059 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -661,6 +661,18 @@ body { } } +#compose-settings-close { + border: none; + color: var(--color-text); +} + +@media (hover: hover) { + #compose-settings-close:hover { + border-color: transparent; + color: var(--color-text); + } +} + /* Tabs */ .settings-tabs { display: flex; @@ -715,6 +727,14 @@ body { margin-bottom: var(--s-2); } +.settings-label__sub { + display: block; + font-size: var(--s-2); + font-weight: 400; + color: var(--color-text-muted); + margin-top: 0.125rem; +} + .settings-select { width: 100%; height: var(--s2); @@ -735,6 +755,10 @@ body { outline-offset: 2px; } +.settings-field { + margin-bottom: var(--s1); +} + .settings-textarea { display: block; width: 100%; diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 465632c..cf2bc7e 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -14,6 +14,7 @@ import { WakeLock } from './wakelock.js'; const STREAM_ENDPOINT = '/v1/chat/voice/stream'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; +const MAX_TURNS_KEY = 'odidere_max_turns'; const ICONS_URL = '/static/icons.svg'; const StreamEventDelta = 'delta'; @@ -102,6 +103,13 @@ class Odidere { this.$settingsClose = document.getElementById('settings-close'); this.$settingsTabs = document.querySelectorAll('.settings-tab'); this.$saveSystemMessage = document.getElementById('save-system-message'); + this.$composeSettingsOverlay = document.getElementById( + 'compose-settings-overlay', + ); + this.$composeSettingsClose = document.getElementById( + 'compose-settings-close', + ); + this.$maxTurnsInput = document.getElementById('max-turns-input'); this.$compose = document.querySelector('.compose'); this.init(); @@ -241,6 +249,18 @@ class Odidere { this.settings.saveSystemMessage(), ); + // --- Compose settings --- + this.$composeSettingsClose.addEventListener('click', () => + this._closeComposeSettings(), + ); + this.$composeSettingsOverlay.addEventListener('click', (e) => { + if (e.target === this.$composeSettingsOverlay) + this._closeComposeSettings(); + }); + this.$maxTurnsInput.addEventListener('input', () => { + this._setMaxTurns(this.$maxTurnsInput.value); + }); + // --- Model/voice change persistence --- this.settings.$model.addEventListener('change', () => this.settings.saveModelSelection(), @@ -297,6 +317,9 @@ class Odidere { case 'settings': this.settings.open(); break; + case 'compose-settings': + this._openComposeSettings(); + break; case 'attach': // Handled by direct listener on $attach. break; @@ -599,6 +622,10 @@ class Odidere { if (systemMessage) { payload.system_message = systemMessage; } + const maxTurns = this._getMaxTurns(); + if (maxTurns > 0) { + payload.max_iterations = maxTurns; + } // Clear attachments and location after building payload. this.clearAttachments(); @@ -1100,6 +1127,30 @@ class Odidere { this.$location.classList.remove('compose__action-btn--location', 'active'); } + // ==================== + // COMPOSE SETTINGS + // ==================== + + _openComposeSettings() { + this.$maxTurnsInput.value = this._getMaxTurns(); + this.$composeSettingsOverlay.classList.add('open'); + this.$composeSettingsClose.focus(); + } + + _closeComposeSettings() { + this.$composeSettingsOverlay.classList.remove('open'); + this.$composeSettings.focus(); + } + + _getMaxTurns() { + const stored = localStorage.getItem(MAX_TURNS_KEY); + return stored ? Number(stored) : 0; + } + + _setMaxTurns(value) { + localStorage.setItem(MAX_TURNS_KEY, String(value)); + } + // ==================== // TWO-PRESS SAFETY PATTERNS // ==================== diff --git a/internal/service/templates/static/body.gohtml b/internal/service/templates/static/body.gohtml index 7fd95ea..f5bf196 100644 --- a/internal/service/templates/static/body.gohtml +++ b/internal/service/templates/static/body.gohtml @@ -4,5 +4,6 @@ {{ template "templates" . }} {{ template "modal/settings" . }} {{ template "modal/edit" . }} + {{ template "modal/compose-settings" . }} {{ end }} diff --git a/internal/service/templates/static/footer/compose.gohtml b/internal/service/templates/static/footer/compose.gohtml index 81c2dc8..1aad779 100644 --- a/internal/service/templates/static/footer/compose.gohtml +++ b/internal/service/templates/static/footer/compose.gohtml @@ -10,6 +10,15 @@
+