Skip to content

Commit 1be1482

Browse files
committed
feat(serve): WebUI enhancements — model switching, theme toggle, session rename, thinking streaming
- Add toggleTheme() — persist light/dark preference in localStorage - Add fetchModels() — populate model picker from GET /api/models - Add switchModel() — send selected model with WS prompt, update at runtime via agent.SwitchModel() - Add session rename — POST /api/sessions/:id with {"name":"..."} from inline ✎ button - Add copy buttons + collapse check to streamed messages - Add IterationCallback for thinking/reasoning content streaming to WebUI - Replace handleSessionDelete with handleSessionByID supporting both DELETE + rename POST - Add handleModelList — GET /api/models returning KnownProfiles as model entries - Add Engine.SetModel() + Agent.SwitchModel() for runtime model hot-swap
1 parent 14631f8 commit 1be1482

4 files changed

Lines changed: 244 additions & 3 deletions

File tree

cmd/odek/serve.go

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/BackendStack21/kode"
1919
"github.com/BackendStack21/kode/internal/config"
2020
"github.com/BackendStack21/kode/internal/llm"
21+
"github.com/BackendStack21/kode/internal/loop"
2122
"github.com/BackendStack21/kode/internal/resource"
2223
"github.com/BackendStack21/kode/internal/session"
2324
"github.com/BackendStack21/kode/internal/skills"
@@ -126,7 +127,8 @@ func serveCmd(args []string) error {
126127
})
127128
mux.HandleFunc("/api/resources", handleResourceSearch(resourceReg))
128129
mux.HandleFunc("/api/sessions", handleSessionList(store))
129-
mux.HandleFunc("/api/sessions/", handleSessionDelete(store))
130+
mux.HandleFunc("/api/sessions/", handleSessionByID(store))
131+
mux.HandleFunc("/api/models", handleModelList)
130132
mux.HandleFunc("/api/cancel", handleCancel)
131133

132134
listener, err := net.Listen("tcp", addr)
@@ -278,6 +280,15 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
278280
"heuristic": event.Heuristic,
279281
})
280282
},
283+
// Stream thinking/reasoning content to the WebUI
284+
IterationCallback: func(info loop.IterationInfo) {
285+
if info.ReasoningContent != "" {
286+
sendFn(map[string]any{
287+
"type": "thinking",
288+
"content": info.ReasoningContent,
289+
})
290+
}
291+
},
281292
})
282293
if err != nil {
283294
return nil, nil, nil, nil, err
@@ -292,6 +303,7 @@ type wsClientMsg struct {
292303
Type string `json:"type"`
293304
Content string `json:"content"`
294305
SessionID string `json:"session_id"`
306+
Model string `json:"model,omitempty"`
295307
}
296308

297309
// ── WebSocket Handler ──────────────────────────────────────────────────
@@ -323,8 +335,9 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
323335
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
324336
defer cancel()
325337

326-
// Track the current session across WebSocket messages
338+
// Track the current session and model across WebSocket messages
327339
var currentSession *session.Session
340+
currentModel := resolved.Model // start with configured model
328341

329342
// Session-level token economics (cumulative across all turns)
330343
var sessionInputTokens, sessionOutputTokens int
@@ -389,6 +402,13 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
389402
continue
390403
}
391404

405+
// Handle runtime model switching
406+
if msg.Model != "" && msg.Model != currentModel {
407+
currentModel = msg.Model
408+
resolved.Model = msg.Model
409+
agent.SwitchModel(msg.Model)
410+
}
411+
392412
// Handle session switch mid-connection (new conversation)
393413
if msg.SessionID != "" && (currentSession == nil || currentSession.ID != msg.SessionID) {
394414
sess, err := store.Load(msg.SessionID)
@@ -679,6 +699,79 @@ func handleSessionList(store *session.Store) http.HandlerFunc {
679699
}
680700
}
681701

702+
func handleSessionByID(store *session.Store) http.HandlerFunc {
703+
return func(w http.ResponseWriter, r *http.Request) {
704+
id := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
705+
706+
switch r.Method {
707+
case http.MethodDelete:
708+
if id == "" {
709+
http.Error(w, "missing session id", http.StatusBadRequest)
710+
return
711+
}
712+
if err := store.Delete(id); err != nil {
713+
http.Error(w, err.Error(), http.StatusInternalServerError)
714+
return
715+
}
716+
w.WriteHeader(http.StatusNoContent)
717+
718+
case http.MethodPost:
719+
// Rename session
720+
if id == "" {
721+
http.Error(w, "missing session id", http.StatusBadRequest)
722+
return
723+
}
724+
var body struct {
725+
Name string `json:"name"`
726+
}
727+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
728+
http.Error(w, "invalid JSON", http.StatusBadRequest)
729+
return
730+
}
731+
sess, err := store.Load(id)
732+
if err != nil {
733+
http.Error(w, "session not found", http.StatusNotFound)
734+
return
735+
}
736+
sess.Task = body.Name
737+
store.Save(sess)
738+
w.Header().Set("Content-Type", "application/json")
739+
json.NewEncoder(w).Encode(sess)
740+
741+
default:
742+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
743+
}
744+
}
745+
}
746+
747+
func handleModelList(w http.ResponseWriter, r *http.Request) {
748+
if r.Method != http.MethodGet {
749+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
750+
return
751+
}
752+
profiles := odek.KnownProfiles
753+
type modelEntry struct {
754+
ID string `json:"id"`
755+
MaxContext int `json:"max_context"`
756+
Description string `json:"description,omitempty"`
757+
}
758+
models := make([]modelEntry, 0, len(profiles))
759+
seen := make(map[string]bool)
760+
for _, p := range profiles {
761+
if seen[p.Prefix] {
762+
continue
763+
}
764+
seen[p.Prefix] = true
765+
models = append(models, modelEntry{
766+
ID: p.Prefix,
767+
MaxContext: p.Profile.MaxContext,
768+
Description: fmt.Sprintf("%s — %dK context", p.Prefix, p.Profile.MaxContext/1024),
769+
})
770+
}
771+
w.Header().Set("Content-Type", "application/json")
772+
json.NewEncoder(w).Encode(models)
773+
}
774+
682775
func handleSessionDelete(store *session.Store) http.HandlerFunc {
683776
return func(w http.ResponseWriter, r *http.Request) {
684777
if r.Method != http.MethodDelete {

cmd/odek/ui/index.html

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@
1717
-webkit-font-smoothing: antialiased;
1818
}
1919

20+
/* ── Light Theme ── */
21+
body.light {
22+
--bg-primary: #ffffff;
23+
--bg-card: #f3f4f6;
24+
--text-primary: #0f172a;
25+
--text-secondary: #475569;
26+
--text-tertiary: #94a3b8;
27+
--border-subtle: #e2e8f0;
28+
--accent: #2563eb;
29+
--accent-subtle: rgba(37,99,235,0.08);
30+
--accent-glow: rgba(37,99,235,0.04);
31+
}
32+
2033
/* ── App Shell ── */
2134
#app {
2235
display: grid;
@@ -89,6 +102,9 @@
89102
.session-item .del-btn { margin-left: auto; opacity: 0; transition: opacity 0.15s; color: var(--text-tertiary); cursor: pointer; font-size: 12px; padding: 0 4px; }
90103
.session-item:hover .del-btn { opacity: 0.6; }
91104
.session-item .del-btn:hover { opacity: 1; color: #ef4444; }
105+
.session-item .rename-btn { opacity: 0; transition: opacity 0.15s; color: var(--text-tertiary); cursor: pointer; font-size: 12px; padding: 0 4px; }
106+
.session-item:hover .rename-btn { opacity: 0.6; }
107+
.session-item .rename-btn:hover { opacity: 1; color: var(--accent); }
92108

93109
/* ── Main Chat ── */
94110
#main {
@@ -1123,6 +1139,22 @@
11231139
}
11241140
#cancel-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
11251141

1142+
/* ── Theme Toggle & Model Picker ── */
1143+
#theme-btn {
1144+
background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary);
1145+
cursor: pointer; font-size: 15px; padding: 4px 6px; border-radius: 6px;
1146+
transition: all .15s; line-height: 1; flex-shrink: 0; display: flex; align-items: center;
1147+
}
1148+
#theme-btn:hover { color: var(--accent); background: rgba(56,189,248,0.06); }
1149+
#model-picker {
1150+
background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border-subtle);
1151+
border-radius: 6px; padding: 2px 6px; font-size: 11px; font-family: var(--font-mono);
1152+
cursor: pointer; outline: none; transition: border-color .15s; max-width: 140px;
1153+
}
1154+
#model-picker:hover { border-color: var(--accent); }
1155+
#model-picker:focus { border-color: var(--accent); }
1156+
#model-picker optgroup, #model-picker option { background: var(--bg-card); color: var(--text-primary); }
1157+
11261158
/* ── Phase 1: Scroll-to-Bottom Button ── */
11271159
#scroll-bottom-btn {
11281160
display: none; position: absolute; bottom: 20px; right: 16px;
@@ -1207,6 +1239,10 @@
12071239
<div class="stats" id="stats-bar"></div>
12081240
<span class="model" id="model-label"></span>
12091241
<span class="session-stats" id="session-stats"></span>
1242+
<select id="model-picker" onchange="switchModel(this.value)" title="Switch model">
1243+
<option value="">Loading...</option>
1244+
</select>
1245+
<button id="theme-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">🌙</button>
12101246
<button id="cancel-btn" onclick="cancelAgent()" aria-label="Cancel">
12111247
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
12121248
</button>
@@ -1331,6 +1367,8 @@ <h3>Delete session?</h3>
13311367
let history = JSON.parse(localStorage.getItem('kode_history') || '[]');
13321368
let historyIdx = -1;
13331369
let attachedFiles = []; // {name, size, content}
1370+
let currentModel = localStorage.getItem('kode_model') || '';
1371+
let availableModels = [];
13341372

13351373
// ── DOM ──
13361374
const messagesEl = document.getElementById('messages');
@@ -1518,6 +1556,12 @@ <h3>Delete session?</h3>
15181556
if (event.model) {
15191557
statsBar.textContent = event.model;
15201558
}
1559+
// Sync model picker with server-confirmed model
1560+
if (event.model && currentModel !== event.model) {
1561+
currentModel = event.model;
1562+
const picker = document.getElementById('model-picker');
1563+
if (picker) picker.value = event.model;
1564+
}
15211565
loadSessions();
15221566
break;
15231567

@@ -1721,6 +1765,12 @@ <h3>Delete session?</h3>
17211765

17221766
streamBubbleEl = wrapper;
17231767
streamContentEl = wrapper.querySelector('#stream-content');
1768+
// Add copy button and collapse check to the stream bubble
1769+
const bubble = wrapper.querySelector('.bubble');
1770+
if (bubble) {
1771+
addCopyButton(bubble);
1772+
checkCollapse(bubble);
1773+
}
17241774
pruneMessages();
17251775
scrollBottom();
17261776
}
@@ -2050,6 +2100,78 @@ <h3>Delete session?</h3>
20502100
.catch(() => showToast('Failed to delete session'));
20512101
};
20522102

2103+
// ── Theme Toggle ──
2104+
window.toggleTheme = function() {
2105+
document.body.classList.toggle('light');
2106+
const isLight = document.body.classList.contains('light');
2107+
localStorage.setItem('kode_theme', isLight ? 'light' : 'dark');
2108+
document.getElementById('theme-btn').textContent = isLight ? '☀️' : '🌙';
2109+
};
2110+
// Restore theme on load
2111+
if (localStorage.getItem('kode_theme') === 'light') {
2112+
document.body.classList.add('light');
2113+
document.getElementById('theme-btn').textContent = '☀️';
2114+
}
2115+
2116+
// ── Model Picker ──
2117+
async function fetchModels() {
2118+
const picker = document.getElementById('model-picker');
2119+
try {
2120+
picker.disabled = true;
2121+
const resp = await fetch('/api/models');
2122+
if (!resp.ok) { picker.innerHTML = '<option value="">Models unavailable</option>'; return; }
2123+
const models = await resp.json();
2124+
availableModels = models;
2125+
if (!models || models.length === 0) {
2126+
picker.innerHTML = '<option value="">No models</option>';
2127+
return;
2128+
}
2129+
// Build <option> list, group by top-level prefix
2130+
let html = '<option value="">— select model —</option>';
2131+
models.forEach(m => {
2132+
const sel = currentModel === m.id ? ' selected' : '';
2133+
html += `<option value="${escapeAttr(m.id)}"${sel}>${escapeHtml(m.description || m.id)}</option>`;
2134+
});
2135+
picker.innerHTML = html;
2136+
if (currentModel) picker.value = currentModel;
2137+
} catch {
2138+
picker.innerHTML = '<option value="">Failed to load</option>';
2139+
} finally {
2140+
picker.disabled = false;
2141+
}
2142+
}
2143+
2144+
window.switchModel = function(modelId) {
2145+
currentModel = modelId;
2146+
if (modelId) {
2147+
localStorage.setItem('kode_model', modelId);
2148+
} else {
2149+
localStorage.removeItem('kode_model');
2150+
}
2151+
showToast(modelId ? 'Model: ' + modelId : 'Using default model');
2152+
};
2153+
2154+
// ── Session Rename ──
2155+
window.renameSession = function(sid, el) {
2156+
const item = el.closest('.session-item');
2157+
if (!item) return;
2158+
const taskEl = item.querySelector('.task');
2159+
const currentName = taskEl ? taskEl.textContent : '';
2160+
const newName = prompt('Rename session:', currentName);
2161+
if (!newName || newName === currentName) return;
2162+
fetch('/api/sessions/' + encodeURIComponent(sid), {
2163+
method: 'POST',
2164+
headers: { 'Content-Type': 'application/json' },
2165+
body: JSON.stringify({ name: newName })
2166+
})
2167+
.then(resp => {
2168+
if (!resp.ok) throw new Error('rename failed');
2169+
loadSessions();
2170+
showToast('Session renamed');
2171+
})
2172+
.catch(() => showToast('Failed to rename session'));
2173+
};
2174+
20532175
// ── Shortcuts ──
20542176
window.toggleShortcuts = function() {
20552177
document.getElementById('shortcuts-overlay').classList.toggle('active');
@@ -2213,7 +2335,8 @@ <h3>Delete session?</h3>
22132335
ws.send(JSON.stringify({
22142336
type: 'prompt',
22152337
content: text,
2216-
session_id: sessionId
2338+
session_id: sessionId,
2339+
model: currentModel || undefined
22172340
}));
22182341
}
22192342

@@ -2523,6 +2646,7 @@ <h3>Delete session?</h3>
25232646
`<div class="session-item${s.id === sessionId ? ' active' : ''}" data-id="${escapeAttr(s.id)}">
25242647
<div style="display:flex;align-items:center;gap:4px">
25252648
<div class="id">${escapeHtml(s.id.slice(0, 8))}</div>
2649+
<span class="rename-btn" title="Rename" onclick="event.stopPropagation();renameSession('${escapeAttr(s.id)}', this)">✎</span>
25262650
<span class="del-btn" title="Delete">✕</span>
25272651
</div>
25282652
<div class="task">${escapeHtml(s.task || '')}</div>
@@ -2540,6 +2664,7 @@ <h3>Delete session?</h3>
25402664
// Show skeleton while connecting
25412665
if (skeletonEl) skeletonEl.classList.add('visible');
25422666
loadSessions();
2667+
fetchModels();
25432668
promptEl.focus();
25442669

25452670
// Handle keyboard shortcuts globally

internal/loop/loop.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,3 +882,12 @@ func classifyToolCall(name, args string) (danger.RiskClass, string) {
882882
return "", ""
883883
}
884884
}
885+
886+
// SetModel updates the LLM model used by this engine at runtime.
887+
// The model string must be a valid OpenAI-compatible model identifier.
888+
func (e *Engine) SetModel(model string) {
889+
if model == "" || e.client == nil {
890+
return
891+
}
892+
e.client.Model = model
893+
}

odek.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,20 @@ func (a *Agent) SkillManager() *skills.SkillManager {
672672
return a.skillManager
673673
}
674674

675+
// SwitchModel updates the LLM model used by this agent at runtime.
676+
// The model string must be a valid OpenAI-compatible model identifier.
677+
// This is safe to call between RunWithMessages calls to switch models
678+
// mid-session. Empty strings are silently ignored.
679+
func (a *Agent) SwitchModel(model string) {
680+
if a == nil || model == "" {
681+
return
682+
}
683+
a.config.Model = model
684+
if a.engine != nil {
685+
a.engine.SetModel(model)
686+
}
687+
}
688+
675689
// expandHome replaces the leading ~/ with the user's home directory.
676690
func expandHome(path string) string {
677691
if strings.HasPrefix(path, "~/") {

0 commit comments

Comments
 (0)