Skip to content

Commit fd0fb16

Browse files
author
Alexey Panfilov
committed
feat(adminapi): streaming chat with multimodal, regenerate, routing override
Rebuild the Chat tab on top of Server-Sent Events so tokens render live, tool calls appear as chips the moment the agent invokes them, and the input stays free while the assistant is thinking. Reuses the existing agent.ProcessStream + router.ChatStream stack; no provider changes needed. - Admin API: POST /chat/stream (SSE: token/tool_call/done/error + 15s heartbeat), POST /chat/pop (drop last user turn, return text); per-user chat_id from X-authentik-username (FNV-64 hash, falls back to -1 under bearer auth); 25 MB body cap for base64 attachments. - Routing: llm.WithForcedRole ctx override, consulted by Router.pick before the global override so per-request role pins don't mutate shared state. - Store: TruncatableStore interface + SQLite/Postgres impls for LastUserMessage + TruncateAfter; agent.PopLastUserTurn helper. - Client: fetch + ReadableStream SSE consumer (no htmx/EventSource), typing dots, tool chips, Stop/Esc abort, drag-drop/paste images, Edit/Regenerate on last bubbles, code-block copy, localStorage draft, role dropdown, smart scroll.
1 parent 15325c3 commit fd0fb16

10 files changed

Lines changed: 823 additions & 102 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,14 @@ Routes:
321321
- `POST /slots/<slot>/assign` (form: `model_id=...`) — `Router.SetProviderModel` with caps from the store; returns refreshed `routing` partial
322322
- `POST /routing/<role>/set` (form: `slot=...`) — `Router.SetRole` (maps `default``primary`); returns refreshed `routing` partial
323323
- `POST /refresh``llm.FetchOpenRouterModels` + upsert; returns refreshed `models_tbody` partial
324+
- `GET /chat` — chat page; history scoped to the caller's per-user chat id (FNV-64 hash of `X-authentik-username`, mapped to negative range; falls back to `-1` under bearer-token auth)
325+
- `POST /chat/stream` — SSE endpoint. Body: `message`, optional `image[]` (base64 data URIs), optional `role` (simple/default/complex). Drives `agent.ProcessStream`; emits events `token` (`{delta}`), `tool_call` (`{name}`), `done` (`{model, tools, text}`), `error` (`{message}`), plus `:ping` comments every 15 s. Body capped at 25 MB; per-request routing override via `llm.WithForcedRole(ctx, role)`
326+
- `POST /chat/pop` — drops the last user turn (via `agent.PopLastUserTurn``store.TruncatableStore`) and returns `{ok, text}`. UI uses this for Regenerate (pop + re-stream with returned text) and Edit (pop + put text back into the input)
327+
- `POST /chat/clear` — resets session via `store.ClearHistory` for the caller's chat id; returns empty message container
324328
- `GET /healthz` — unauthenticated liveness probe
325329

330+
**Chat tab notes:** `WriteTimeout: 0` on the admin HTTP server (long agent chains). Client is a fetch + `ReadableStream` SSE consumer (not htmx / `EventSource`) so the same endpoint can drive a future native app. Typing indicator is three bouncing dots until the first `token` event; tool-call chips (🔧) appear inline in the bot bubble meta; `Esc` or the Stop button abort via `AbortController`. Markdown rendered client-side via `marked.js` (CDN) on each `done` event, with copy-buttons hydrated on `<pre>` blocks. Draft persisted in `localStorage`. Hover actions (Edit on last user bubble, Regenerate on last bot bubble) call `/chat/pop`. Images pasted or dropped into the form are attached as `image_url` parts in a multimodal `llm.Message`.
331+
326332
The admin API never imports the Authentik library — it only _trusts_ the forwarded header. All actual authentication happens upstream in Traefik via the `authentik-auth` middleware. When running without authentik (local dev), the bearer token flow is the default.
327333

328334
### Persistent settings (`kv_settings`)

internal/adminapi/chat.go

Lines changed: 194 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,47 @@ package adminapi
22

33
import (
44
"context"
5-
"html"
5+
"encoding/json"
6+
"fmt"
7+
"hash/fnv"
68
"net/http"
79
"strings"
10+
"sync"
811
"time"
912

1013
"telegram-agent/internal/llm"
1114
)
1215

13-
// adminChatID is the dedicated chat ID for the admin web UI — negative so it
14-
// never collides with real Telegram user IDs.
15-
const adminChatID int64 = -1
16+
// defaultAdminChatID is used when no forward-auth user is present (local dev
17+
// via bearer token). Negative so it never collides with real Telegram user IDs.
18+
const defaultAdminChatID int64 = -1
1619

1720
// ChatAgent is the subset of agent.Agent the chat handler needs.
1821
type ChatAgent interface {
1922
Process(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(string)) (string, error)
23+
ProcessStream(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(string), onChunk func(string)) (string, error)
2024
GetChatHistory(chatID int64) []llm.Message
2125
ClearChatHistory(chatID int64)
26+
PopLastUserTurn(chatID int64) (string, bool)
2227
}
2328

2429
// SetAgent wires the agent so the Chat tab can process messages.
2530
func (s *Server) SetAgent(a ChatAgent) { s.agent = a }
2631

32+
// chatIDFor derives a per-user chat_id from the forward-auth username when
33+
// available, otherwise returns the shared dev sentinel. Mapped to the negative
34+
// range so it never collides with positive Telegram user IDs; offset by -2 so
35+
// it also can't collide with the dev sentinel (-1).
36+
func (s *Server) chatIDFor(r *http.Request) int64 {
37+
user := strings.TrimSpace(r.Header.Get(s.cfg.ForwardAuthHeader))
38+
if user == "" {
39+
return defaultAdminChatID
40+
}
41+
h := fnv.New64a()
42+
_, _ = h.Write([]byte(user))
43+
return -int64(h.Sum64()&0x7FFFFFFFFFFFFFFF) - 2
44+
}
45+
2746
// routedModel returns the actual model ID used on the last router call,
2847
// falling back to the slot name when the provider doesn't expose CurrentModel.
2948
func routedModel(r interface {
@@ -54,7 +73,8 @@ type chatData struct {
5473
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
5574
data := chatData{ActiveTab: "chat"}
5675
if s.agent != nil {
57-
for _, m := range s.agent.GetChatHistory(adminChatID) {
76+
chatID := s.chatIDFor(r)
77+
for _, m := range s.agent.GetChatHistory(chatID) {
5878
if m.Role != "user" && m.Role != "assistant" {
5979
continue
6080
}
@@ -67,40 +87,163 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
6787
}
6888
}
6989

70-
func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
90+
// handleChatStream is a Server-Sent Events endpoint that streams the
91+
// agent's response back to the client. Event types:
92+
//
93+
// token — {"delta": "..."} incremental assistant text
94+
// tool_call — {"name": "..."} a tool is being invoked
95+
// done — {"model": "...", "tools": [...], "text": "..."} final reply
96+
// error — {"message": "..."} unrecoverable failure
97+
//
98+
// The endpoint also emits ":ping" comment lines every 15 s to keep the
99+
// connection alive through reverse proxies.
100+
func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
71101
if r.Method != http.MethodPost {
72102
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
73103
return
74104
}
105+
flusher, ok := w.(http.Flusher)
106+
if !ok {
107+
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
108+
return
109+
}
110+
// Allow up to 25 MB of request body so base64-encoded images fit.
111+
r.Body = http.MaxBytesReader(w, r.Body, 25*1024*1024)
75112
if err := r.ParseForm(); err != nil {
76-
http.Error(w, "bad request", http.StatusBadRequest)
113+
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
77114
return
78115
}
79116
text := strings.TrimSpace(r.FormValue("message"))
80-
if text == "" {
81-
w.WriteHeader(http.StatusOK)
117+
images := r.Form["image"]
118+
if text == "" && len(images) == 0 {
119+
http.Error(w, "empty message", http.StatusBadRequest)
82120
return
83121
}
84122
if s.agent == nil {
85-
writeChatFragment(w, text, "", nil, "agent not configured", "")
123+
http.Error(w, "agent not configured", http.StatusServiceUnavailable)
86124
return
87125
}
88126

127+
userMsg := buildUserMessage(text, images)
128+
chatID := s.chatIDFor(r)
129+
130+
// Per-request routing role override from the UI dropdown.
131+
ctxRole := strings.TrimSpace(r.FormValue("role"))
132+
switch ctxRole {
133+
case "", "simple", "default", "complex":
134+
// valid (empty = auto-route)
135+
default:
136+
ctxRole = ""
137+
}
138+
139+
w.Header().Set("Content-Type", "text/event-stream")
140+
w.Header().Set("Cache-Control", "no-cache")
141+
w.Header().Set("Connection", "keep-alive")
142+
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/traefik buffering
143+
w.WriteHeader(http.StatusOK)
144+
flusher.Flush()
145+
89146
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
90147
defer cancel()
148+
if ctxRole != "" {
149+
ctx = llm.WithForcedRole(ctx, ctxRole)
150+
}
91151

152+
// All writes to w go through sendMu. ProcessStream calls callbacks on its
153+
// goroutine; the heartbeat goroutine ticks on another; serialising is
154+
// cheaper than a funnel channel for this low rate.
155+
var sendMu sync.Mutex
156+
write := func(event, data string) {
157+
sendMu.Lock()
158+
defer sendMu.Unlock()
159+
writeSSE(w, flusher, event, data)
160+
}
161+
162+
// Heartbeat keeps the connection open through idle-timeout proxies.
163+
hbStop := make(chan struct{})
164+
go func() {
165+
ticker := time.NewTicker(15 * time.Second)
166+
defer ticker.Stop()
167+
for {
168+
select {
169+
case <-ctx.Done():
170+
return
171+
case <-hbStop:
172+
return
173+
case <-ticker.C:
174+
sendMu.Lock()
175+
_, _ = fmt.Fprint(w, ": ping\n\n")
176+
flusher.Flush()
177+
sendMu.Unlock()
178+
}
179+
}
180+
}()
181+
defer close(hbStop)
182+
183+
var toolsMu sync.Mutex
92184
var tools []string
93-
resp, err := s.agent.Process(ctx, adminChatID, llm.Message{Role: "user", Content: text}, func(tool string) {
94-
tools = append(tools, tool)
95-
})
96185

97-
errStr := ""
186+
var lastLen int
187+
onChunk := func(accumulated string) {
188+
if len(accumulated) <= lastLen {
189+
return
190+
}
191+
delta := accumulated[lastLen:]
192+
lastLen = len(accumulated)
193+
b, _ := json.Marshal(map[string]string{"delta": delta})
194+
write("token", string(b))
195+
}
196+
onToolCall := func(name string) {
197+
toolsMu.Lock()
198+
tools = append(tools, name)
199+
toolsMu.Unlock()
200+
b, _ := json.Marshal(map[string]string{"name": name})
201+
write("tool_call", string(b))
202+
// Each tool_call resets the streaming buffer on the client (the agent
203+
// will re-run the LLM after executing tools, producing a new text
204+
// stream). Reset the server-side length tracker so we don't clip the
205+
// next assistant text.
206+
lastLen = 0
207+
}
208+
209+
resp, err := s.agent.ProcessStream(ctx, chatID, userMsg, onToolCall, onChunk)
210+
98211
if err != nil {
99-
errStr = err.Error()
212+
b, _ := json.Marshal(map[string]string{"message": err.Error()})
213+
write("error", string(b))
214+
return
100215
}
101216

102-
model := routedModel(s.router)
103-
writeChatFragment(w, text, resp, tools, errStr, model)
217+
toolsMu.Lock()
218+
finalTools := append([]string(nil), tools...)
219+
toolsMu.Unlock()
220+
payload := map[string]any{
221+
"model": routedModel(s.router),
222+
"tools": finalTools,
223+
"text": resp,
224+
}
225+
b, _ := json.Marshal(payload)
226+
write("done", string(b))
227+
}
228+
229+
// handleChatPop drops the last user turn and returns the removed text. The
230+
// UI uses this for Edit — the text goes back into the input field so the user
231+
// can revise and resubmit. For Regenerate the UI calls this then immediately
232+
// calls /chat/stream with the same text.
233+
func (s *Server) handleChatPop(w http.ResponseWriter, r *http.Request) {
234+
if r.Method != http.MethodPost {
235+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
236+
return
237+
}
238+
if s.agent == nil {
239+
http.Error(w, "agent not configured", http.StatusServiceUnavailable)
240+
return
241+
}
242+
text, ok := s.agent.PopLastUserTurn(s.chatIDFor(r))
243+
w.Header().Set("Content-Type", "application/json")
244+
payload := map[string]any{"ok": ok, "text": text}
245+
b, _ := json.Marshal(payload)
246+
_, _ = w.Write(b)
104247
}
105248

106249
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
@@ -109,60 +252,46 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
109252
return
110253
}
111254
if s.agent != nil {
112-
s.agent.ClearChatHistory(adminChatID)
255+
s.agent.ClearChatHistory(s.chatIDFor(r))
113256
}
114257
w.Header().Set("Content-Type", "text/html; charset=utf-8")
115-
w.Write([]byte(`<div id="chat-messages" class="chat-messages"></div>`)) //nolint:errcheck
258+
_, _ = w.Write([]byte(`<div id="chat-messages" class="chat-messages"></div>`))
116259
}
117260

118-
func writeChatFragment(w http.ResponseWriter, userText, botText string, tools []string, errStr, model string) {
119-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
120-
ts := time.Now().Format("15:04")
121-
122-
var sb strings.Builder
123-
124-
// User bubble.
125-
sb.WriteString(`<div class="chat-msg chat-msg--user">`)
126-
sb.WriteString(`<div class="chat-msg__meta">You &middot; `)
127-
sb.WriteString(ts)
128-
sb.WriteString(`</div><div class="chat-msg__body">`)
129-
sb.WriteString(html.EscapeString(userText))
130-
sb.WriteString(`</div></div>`)
131-
132-
// Assistant bubble.
133-
sb.WriteString(`<div class="chat-msg chat-msg--bot">`)
134-
// Meta line: model + tools.
135-
sb.WriteString(`<div class="chat-msg__meta">`)
136-
if model != "" {
137-
sb.WriteString(html.EscapeString(model))
138-
sb.WriteString(` &middot; `)
139-
}
140-
sb.WriteString(ts)
141-
if len(tools) > 0 {
142-
sb.WriteString(` &middot; <span class="chat-tools">`)
143-
for i, t := range tools {
144-
if i > 0 {
145-
sb.WriteString(`, `)
146-
}
147-
sb.WriteString(html.EscapeString(t))
261+
// buildUserMessage packs text + any attached images into an llm.Message.
262+
// When images are present we build a multimodal Parts payload; otherwise
263+
// we stick with plain Content so non-vision providers see a simple string.
264+
// Accepts data URIs ("data:image/...;base64,...") or raw http(s) URLs.
265+
func buildUserMessage(text string, images []string) llm.Message {
266+
if len(images) == 0 {
267+
return llm.Message{Role: "user", Content: text}
268+
}
269+
parts := make([]llm.ContentPart, 0, len(images)+1)
270+
if text != "" {
271+
parts = append(parts, llm.ContentPart{Type: "text", Text: text})
272+
}
273+
for _, url := range images {
274+
url = strings.TrimSpace(url)
275+
if url == "" {
276+
continue
148277
}
149-
sb.WriteString(`</span>`)
278+
parts = append(parts, llm.ContentPart{
279+
Type: "image_url",
280+
ImageURL: &llm.ImageURL{URL: url},
281+
})
150282
}
151-
sb.WriteString(`</div>`)
283+
return llm.Message{Role: "user", Parts: parts}
284+
}
152285

153-
// Body: raw markdown stored in data-md, rendered by marked.js on the client.
154-
if errStr != "" {
155-
sb.WriteString(`<div class="chat-msg__body"><span class="chat-error">Error: `)
156-
sb.WriteString(html.EscapeString(errStr))
157-
sb.WriteString(`</span></div>`)
158-
} else {
159-
sb.WriteString(`<div class="chat-msg__body md" data-md="`)
160-
sb.WriteString(html.EscapeString(botText))
161-
sb.WriteString(`"></div>`)
286+
// writeSSE emits one SSE event. Multi-line data payloads are split into
287+
// separate `data: ` lines per the SSE spec.
288+
func writeSSE(w http.ResponseWriter, f http.Flusher, event, data string) {
289+
if event != "" {
290+
_, _ = fmt.Fprintf(w, "event: %s\n", event)
162291
}
163-
164-
sb.WriteString(`</div>`) // .chat-msg--bot
165-
sb.WriteString(`<div class="chat-scroll-sentinel"></div>`)
166-
167-
w.Write([]byte(sb.String())) //nolint:errcheck
292+
for _, line := range strings.Split(data, "\n") {
293+
_, _ = fmt.Fprintf(w, "data: %s\n", line)
294+
}
295+
_, _ = fmt.Fprint(w, "\n")
296+
f.Flush()
168297
}

internal/adminapi/server.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
133133
mux.Handle("/mcp", authed(http.HandlerFunc(s.handleMCP)))
134134
mux.Handle("/mcp/", authed(http.HandlerFunc(s.handleMCPRouter))) // dispatches {name}/set | {name}/delete
135135
mux.Handle("/chat", authed(http.HandlerFunc(s.handleChat)))
136-
mux.Handle("/chat/send", authed(http.HandlerFunc(s.handleChatSend)))
136+
mux.Handle("/chat/stream", authed(http.HandlerFunc(s.handleChatStream)))
137+
mux.Handle("/chat/pop", authed(http.HandlerFunc(s.handleChatPop)))
137138
mux.Handle("/chat/clear", authed(http.HandlerFunc(s.handleChatClear)))
138139
}
139140

0 commit comments

Comments
 (0)