orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// Command chat serves chat.bythewood.me: a conversation with a small model
2// running on Isaac's own card, with tools for anything it cannot know, history
3// in SQLite, and an incognito mode that writes nothing down.
4package main
5
6import (
7 "context"
8 "encoding/json"
9 "flag"
10 "fmt"
11 "html/template"
12 "log/slog"
13 "mime/multipart"
14 "net/http"
15 "os"
16 "strconv"
17 "strings"
18 "time"
19
20 "chat.bythewood.me/tools"
21 "chat.bythewood.me/web"
22 "github.com/yuin/goldmark"
23 "github.com/yuin/goldmark/extension"
24)
25
26const (
27 listenAddr = ":8000"
28 sourceURL = "https://github.com/overshard/orchard/tree/main/sites/chat.bythewood.me"
29 selfSource = "chat"
30)
31
32// csp is the same shape as every other site here. 'unsafe-inline' is for the
33// analytics collector snippet, which is an inline script by design, and for the
34// handful of inline style attributes the templates set.
35func csp() string {
36 return strings.Join([]string{
37 "default-src 'self'",
38 "script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
39 "style-src 'self' 'unsafe-inline'",
40 "img-src 'self' data:",
41 "font-src 'self'",
42 "connect-src 'self' https://analytics.bythewood.me",
43 "base-uri 'self'",
44 "form-action 'self'",
45 "frame-ancestors 'none'",
46 }, "; ")
47}
48
49type site struct {
50 auth *web.Authenticator
51 llm *LLM
52 engine *Engine
53 store *Store
54 runs *Runs
55 queue *Queue
56 hub *Hub
57 comp *Compactor
58 tpl *template.Template
59 md goldmark.Markdown
60 label string
61 ctxSize int
62 dev bool
63}
64
65func round1(f float64) float64 { return float64(int(f*10+0.5)) / 10 }
66
67func main() {
68 var (
69 addr = flag.String("addr", env("CHAT_ADDR", listenAddr), "listen address")
70 llmURL = flag.String("llm", env("LLM_URL", "http://orchard-llm:8000"), "model gateway base url")
71 llmKey = flag.String("llm-key", os.Getenv("LLM_KEY"), "api key for the model gateway")
72 verify = flag.String("verify", "", "development only: check sessions against this url instead of auth")
73 model = flag.String("model", env("LLM_MODEL", "local"), "model name the server answers to")
74 label = flag.String("model-name", env("LLM_NAME", "Ornith 1.5 9B"), "readable model name, shown in the UI and told to the model")
75 dbPath = flag.String("db", env("CHAT_DB", "data/chat.db"), "conversation database")
76 wikiURL = flag.String("wiki", env("WIKI_URL", "http://orchard-wiki:8000"), "offline wikipedia base url")
77 ctxSize = flag.Int("ctx", envInt("LLM_CTX", 32768), "model context window in tokens")
78 health = flag.Bool("healthcheck", false, "probe the local server and exit")
79 )
80 flag.Parse()
81 web.SetupLogging()
82
83 if *health {
84 if err := web.HealthCheck("http://127.0.0.1"+*addr+"/healthz", 3*time.Second); err != nil {
85 os.Exit(1)
86 }
87 return
88 }
89
90 // Past the healthcheck branch, so a HEALTHCHECK invocation does not start a
91 // queue it will never flush. This was the only one of the eleven sites not
92 // shipping, which is why logging.bythewood.me had no record of chat at all.
93 web.ShipLogs(selfSource, web.HTTPSink())
94
95 store, err := OpenStore(*dbPath)
96 if err != nil {
97 slog.Error("open store", "err", err)
98 os.Exit(1)
99 }
100 defer store.Close()
101
102 tools.WikiBase = *wikiURL
103
104 llm := NewLLM(*llmURL, *model, *llmKey)
105 // The verifier can only be moved in a development build. Reloaded is false
106 // in the shipped image, so this cannot be turned on however the environment
107 // is set, the same fence assets_disk.go has.
108 auth := web.NewAuthenticator()
109 if Reloaded && *verify != "" {
110 slog.Warn("checking sessions against a development verifier", "url", *verify)
111 auth = web.NewAuthenticatorAt(*verify)
112 }
113
114 s := &site{
115 auth: auth,
116 llm: llm, engine: NewEngine(llm, *label), store: store, label: *label,
117 comp: NewCompactor(llm, *ctxSize), ctxSize: *ctxSize, dev: Reloaded,
118 runs: NewRuns(), queue: NewQueue(), hub: NewHub(),
119 md: goldmark.New(goldmark.WithExtensions(extension.GFM),
120 goldmark.WithRendererOptions()),
121 }
122 s.engine.Render = s.render
123 // The remember tool writes to this process's own database rather than to a
124 // service, so it is handed the store rather than a url.
125 s.engine.Deps().Memory = memoryStore{store}
126 s.engine.Deps().History = historyStore{store}
127 // The rate limit boxes survive a restart. Without this every deploy asked a
128 // host that was already refusing, which is how a ban gets renewed rather
129 // than expiring.
130 s.engine.RestoreGuard(store, store.Penalties())
131 s.engine.RestoreSpend(store.Spend(tools.SearchHost))
132 // Finished runs are kept a while so a tab coming back can still read one,
133 // and swept after that rather than held until the process restarts.
134 go func() {
135 for range time.Tick(5 * time.Minute) {
136 s.runs.Sweep(time.Now())
137 }
138 }()
139 if err := s.loadTemplates(); err != nil {
140 slog.Error("templates", "err", err)
141 os.Exit(1)
142 }
143
144 mux := http.NewServeMux()
145 // Everything is gated. This site can read Isaac's own infrastructure
146 // through its tools, so there is no anonymous surface at all, not even a
147 // landing page, which is the difference between this and the dashboards
148 // that show a signed out visitor something.
149 // The root is the one public page: what this is and a way in. Everything
150 // past it needs a session, and a signed in visitor gets the app here rather
151 // than the pitch.
152 mux.HandleFunc("GET /{$}", s.root)
153 mux.HandleFunc("GET /c/{id}", s.auth.RequireAuth(s.page))
154 // Memory. Reading and deleting are plain pages and plain posts, and the
155 // only way to write is the one that goes through the model.
156 mux.HandleFunc("GET /api/memory", s.auth.RequireAuthJSON(s.memoryList))
157 mux.HandleFunc("POST /api/memory/teach", s.auth.RequireAuthJSON(s.memoryTeach))
158 mux.HandleFunc("DELETE /api/memory/{id}", s.auth.RequireAuthJSON(s.memoryDelete))
159 mux.HandleFunc("DELETE /api/memory", s.auth.RequireAuthJSON(s.memoryForget))
160 // Stylesheets and scripts, which the landing page needs and which carry
161 // nothing a session would protect.
162 mux.Handle("GET /static/", s.static())
163 // Signing in happens on auth.bythewood.me, and this is here so an old
164 // bookmark lands somewhere useful rather than on a redirect loop.
165 mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
166 http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
167 })
168 // Not gated, because the container's own health check calls it over
169 // loopback with no cookie and it says nothing.
170 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
171 w.Header().Set("Cache-Control", "no-store")
172 fmt.Fprintln(w, "ok")
173 })
174 mux.HandleFunc("POST /api/send", s.auth.RequireAuthJSON(s.send))
175 mux.HandleFunc("GET /api/attach/{id}", s.auth.RequireAuthJSON(s.attach))
176 mux.HandleFunc("POST /api/stop/{id}", s.auth.RequireAuthJSON(s.stop))
177 mux.HandleFunc("GET /api/conversations", s.auth.RequireAuthJSON(s.listConversations))
178 mux.HandleFunc("GET /api/conversation/{id}", s.auth.RequireAuthJSON(s.getConversation))
179 mux.HandleFunc("DELETE /api/conversation/{id}", s.auth.RequireAuthJSON(s.deleteConversation))
180 mux.HandleFunc("DELETE /api/conversations", s.auth.RequireAuthJSON(s.deleteAll))
181 mux.HandleFunc("GET /api/status", s.auth.RequireAuthJSON(s.status))
182 mux.HandleFunc("GET /api/events", s.auth.RequireAuthJSON(s.events))
183 // The readings behind a chart. Gated like everything else here, and read
184 // only: both go out to a public source and neither touches this estate.
185 mux.HandleFunc("GET /api/widget/ticker", s.auth.RequireAuthJSON(s.widgetTicker))
186 mux.HandleFunc("GET /api/widget/weather", s.auth.RequireAuthJSON(s.widgetWeather))
187
188 slog.Info("chat listening", "addr", *addr, "llm", *llmURL, "model", *label,
189 "db", *dbPath, "reloaded", Reloaded)
190 web.Serve(*addr, web.Chain(mux, web.Recovered, web.Logged, web.SecurityHeaders(csp())))
191}
192
193func env(k, def string) string {
194 if v := os.Getenv(k); v != "" {
195 return v
196 }
197 return def
198}
199
200func envInt(k string, def int) int {
201 if v := os.Getenv(k); v != "" {
202 if n, err := strconv.Atoi(v); err == nil {
203 return n
204 }
205 }
206 return def
207}
208
209func (s *site) loadTemplates() error {
210 // Reading assets off disk means their hashes change while the process is
211 // up, so the reload path drops the cached versions with the templates.
212 if Reloaded {
213 resetAssetVersions()
214 }
215 t, err := template.New("").Funcs(template.FuncMap{
216 "when": fmtWhen,
217 "asset": assetURL,
218 }).ParseFS(assets(), "templates/*.html")
219 if err != nil {
220 return err
221 }
222 s.tpl = t
223 return nil
224}
225
226func (s *site) static() http.Handler {
227 fs := http.FileServerFS(assets())
228 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229 // Assets come off disk in development so a CSS edit shows up on
230 // reload, and out of the binary in the release build. That split is
231 // why an embedded stylesheet does not silently ignore an edit.
232 if Reloaded {
233 w.Header().Set("Cache-Control", "no-store")
234 } else {
235 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
236 }
237 fs.ServeHTTP(w, r)
238 })
239}
240
241// root shows the app to anyone signed in and the landing page to everyone else.
242func (s *site) root(w http.ResponseWriter, r *http.Request) {
243 if s.auth.Authenticated(r) {
244 s.page(w, r)
245 return
246 }
247 s.landing(w, r)
248}
249
250func (s *site) landing(w http.ResponseWriter, r *http.Request) {
251 if Reloaded {
252 if err := s.loadTemplates(); err != nil {
253 http.Error(w, err.Error(), 500)
254 return
255 }
256 }
257 w.Header().Set("Content-Type", "text/html; charset=utf-8")
258 w.Header().Set("Cache-Control", "no-store")
259 if err := s.tpl.ExecuteTemplate(w, "landing.html", map[string]any{
260 "Source": sourceURL, "Model": s.label, "Ctx": kfmt(s.ctxSize),
261 }); err != nil {
262 slog.Error("render landing", "err", err)
263 }
264}
265
266func (s *site) page(w http.ResponseWriter, r *http.Request) {
267 if Reloaded {
268 if err := s.loadTemplates(); err != nil {
269 http.Error(w, err.Error(), 500)
270 return
271 }
272 }
273 convs, _ := s.store.List(40)
274 nConv, nMsg := s.store.Count()
275 data := map[string]any{
276 "Conversations": convs,
277 "Source": sourceURL,
278 "Dev": s.dev,
279 "Model": s.label,
280 "Stats": map[string]int{"Conversations": nConv, "Messages": nMsg},
281 "Ctx": *(&s.ctxSize),
282 "Active": r.PathValue("id"),
283 }
284 w.Header().Set("Content-Type", "text/html; charset=utf-8")
285 if err := s.tpl.ExecuteTemplate(w, "app.html", data); err != nil {
286 slog.Error("render", "err", err)
287 }
288}
289
290type sendReq struct {
291 Message string `json:"message"`
292 ConvID string `json:"conversation_id"`
293 // The id the browser made up for a conversation that does not exist yet, so
294 // a turn started before the first row is written is still findable if the
295 // tab goes away in the middle of it.
296 RunID string `json:"run_id"`
297 Incognito bool `json:"incognito"`
298}
299
300// A turn's whole upload, across every file on it. The per file ceiling is in
301// attach.go and this is the one that stops ten of them at once.
302const maxUploadBytes = 64 << 20
303
304// readSend accepts either JSON or a multipart form, since a turn carrying files
305// cannot be JSON and a turn without them should not have to be a form.
306func readSend(w http.ResponseWriter, r *http.Request) (sendReq, []filePart, error) {
307 var req sendReq
308 if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
309 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
310 return req, nil, fmt.Errorf("bad request")
311 }
312 req.Message = strings.TrimSpace(req.Message)
313 return req, nil, nil
314 }
315 r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
316 if err := r.ParseMultipartForm(16 << 20); err != nil {
317 return req, nil, fmt.Errorf("that is more than %s of attachments", humanSize(maxUploadBytes))
318 }
319 // Anything over the memory limit was spooled to a temp file, so the parts
320 // are turned into text here and the spill is dropped before the turn runs,
321 // rather than sitting there for the twelve minutes a turn may take.
322 defer func() { _ = r.MultipartForm.RemoveAll() }()
323
324 req.Message = strings.TrimSpace(r.FormValue("message"))
325 req.ConvID = strings.TrimSpace(r.FormValue("conversation_id"))
326 req.RunID = strings.TrimSpace(r.FormValue("run_id"))
327 req.Incognito = r.FormValue("incognito") == "true"
328 var headers []*multipart.FileHeader
329 if r.MultipartForm != nil {
330 headers = r.MultipartForm.File["files"]
331 }
332 return req, readFiles(headers), nil
333}
334
335// send starts one turn and streams it. Server sent events rather than a
336// websocket because the traffic is one way and this survives a proxy.
337//
338// The turn itself runs detached, so the browser is a reader and not the thing
339// the work depends on. Closing the tab drops the reader and the turn carries on.
340func (s *site) send(w http.ResponseWriter, r *http.Request) {
341 req, parts, err := readSend(w, r)
342 if err != nil {
343 http.Error(w, err.Error(), 400)
344 return
345 }
346 // A turn carrying files needs no message, since the files are the question.
347 if req.Message == "" && len(parts) == 0 {
348 http.Error(w, "empty message", 400)
349 return
350 }
351
352 // A new conversation has no id until its first turn is stored, so the
353 // browser sends one it made up and the run is keyed by that until the store
354 // hands over the real one.
355 key := req.ConvID
356 if key == "" {
357 key = req.RunID
358 }
359 if key == "" {
360 http.Error(w, "no conversation or run id", 400)
361 return
362 }
363
364 var session string
365 if c, err := r.Cookie(web.SessionCookie); err == nil {
366 session = c.Value
367 }
368
369 rn := s.runs.Start(key)
370 // Incognito is left out of every one of these. A mode that writes nothing
371 // down must not announce itself to a tab on another device either.
372 if !req.Incognito {
373 s.hub.Publish(HubEvent{Kind: "started", ConvID: key})
374 }
375 // Detached on purpose. r.Context() dies with the tab, and a turn that has
376 // spent two minutes fetching should not be thrown away because a phone
377 // locked.
378 ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 12*time.Minute)
379 rn.setCancel(cancel)
380 go func() {
381 defer cancel()
382 defer rn.Finish()
383 defer func() {
384 if p := recover(); p != nil {
385 slog.Error("a turn panicked", "err", p)
386 rn.Emit(Event{Kind: "error", Text: "that turn failed"})
387 }
388 }()
389 s.turn(ctx, rn, key, req, parts, session)
390 }()
391 s.streamRun(w, r, rn)
392}
393
394// streamRun writes a run to one browser: everything it has already produced,
395// then whatever comes next until the turn ends or this reader goes away.
396// events is the meta stream every open tab holds, so a turn started anywhere is
397// known everywhere. It carries which conversation changed and never what was
398// said: a tab is told to go and read, and the run is still the only place an
399// answer is assembled.
400func (s *site) events(w http.ResponseWriter, r *http.Request) {
401 w.Header().Set("Content-Type", "text/event-stream")
402 w.Header().Set("Cache-Control", "no-store")
403 w.Header().Set("X-Accel-Buffering", "no")
404 rc := http.NewResponseController(w)
405 _ = rc.SetWriteDeadline(time.Time{})
406
407 ch, cancel := s.hub.Subscribe()
408 defer cancel()
409
410 // An immediate frame, so a proxy that buffers until it sees output lets the
411 // stream through rather than holding it until the first real event.
412 if _, err := fmt.Fprint(w, ": open\n\n"); err != nil || rc.Flush() != nil {
413 return
414 }
415 // A comment every half minute keeps the connection through Cloudflare and
416 // Caddy, neither of which will hold an idle stream open indefinitely.
417 beat := time.NewTicker(30 * time.Second)
418 defer beat.Stop()
419
420 for {
421 select {
422 case <-r.Context().Done():
423 return
424 case ev, ok := <-ch:
425 if !ok {
426 return
427 }
428 b := ev.frame()
429 if b == nil {
430 continue
431 }
432 if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
433 return
434 }
435 if rc.Flush() != nil {
436 return
437 }
438 case <-beat.C:
439 if _, err := fmt.Fprint(w, ": beat\n\n"); err != nil || rc.Flush() != nil {
440 return
441 }
442 }
443 }
444}
445
446func (s *site) streamRun(w http.ResponseWriter, r *http.Request, tr *turnRun) {
447 w.Header().Set("Content-Type", "text/event-stream")
448 w.Header().Set("Cache-Control", "no-store")
449 w.Header().Set("X-Accel-Buffering", "no")
450 // A type assertion for http.Flusher does not survive the request logger,
451 // which wraps the writer in a recorder that only promotes three methods.
452 // ResponseController follows Unwrap and works through it.
453 rc := http.NewResponseController(w)
454 // No write bound, since a turn runs for minutes and any deadline here is a
455 // ceiling on how long the stream may stay open.
456 _ = rc.SetWriteDeadline(time.Time{})
457
458 backlog, ch, live := tr.Follow()
459 write := func(b []byte) bool {
460 if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
461 return false
462 }
463 return rc.Flush() == nil
464 }
465 for _, b := range backlog {
466 if !write(b) {
467 if live {
468 tr.Unfollow(ch)
469 }
470 return
471 }
472 }
473 if !live {
474 return
475 }
476 defer tr.Unfollow(ch)
477 for {
478 select {
479 case <-r.Context().Done():
480 return
481 case b, ok := <-ch:
482 if !ok {
483 return
484 }
485 if !write(b) {
486 return
487 }
488 }
489 }
490}
491
492// turn is the work, with no http in it. It writes into the run rather than to a
493// response, which is what lets it outlive the request that started it.
494func (s *site) turn(ctx context.Context, rn *turnRun, key string, req sendReq, parts []filePart, session string) {
495 prompt := composeTurn(req.Message, parts)
496 emit := func(e Event) { rn.Emit(e) }
497 tr := NewTrace(emit)
498
499 // One turn at a time. There is one card behind this and llama.cpp runs it
500 // with a single slot, so two turns at once interleave and both take longer
501 // than they would have taken in order. The waiting is shown rather than
502 // hidden, since a tab sat on "thinking" because another is ahead looks
503 // broken and one that says it is second in line looks like a queue.
504 release, ok := s.queue.Enter(ctx, func(q QueueState) {
505 emit(Event{Kind: "status", Text: waitingLabel(q)})
506 })
507 if !ok {
508 emit(Event{Kind: "error", Text: "that turn was stopped before it started"})
509 return
510 }
511 defer release()
512 // Every model call this turn makes hangs off this context, including the
513 // ones the tools start, so marking it here is what keeps the gateway from
514 // writing down what the local database is not writing down either.
515 if req.Incognito {
516 ctx = WithIncognito(ctx)
517 }
518
519 // Load the conversation and build the window before anything else, since
520 // compaction may need a model call of its own.
521 var history []Message
522 var conv Conversation
523 var stored []Stored
524 if !req.Incognito && req.ConvID != "" {
525 conv, _ = s.store.Get(req.ConvID)
526 stored, _ = s.store.Messages(req.ConvID)
527 var summary string
528 var covered int
529 var changed bool
530 history, summary, covered, changed = s.comp.Window(ctx, conv, stored)
531 if changed {
532 emit(Event{Kind: "status", Text: "compacting"})
533 _ = s.store.SetSummary(req.ConvID, summary, covered)
534 tr.Add(Step{Kind: "compact", Label: "rewrote the older history as a summary",
535 Out: summary, Meta: itoa(covered) + " earlier messages replaced"})
536 }
537 }
538
539 // Warm the weights while the window is being built rather than after.
540 go s.llm.Warm(context.WithoutCancel(ctx))
541
542 // Retrieval is against what the user typed, not the composed prompt, since
543 // the text of an attachment would swamp the scoring with its own words.
544 recalled := s.store.Relevant(req.Message, factsPerTurn)
545 if len(recalled) > 0 {
546 tr.Add(Step{Kind: "memory", Label: "recalled what it knows about Isaac",
547 In: req.Message, Out: memoryBlock(recalled),
548 Meta: itoa(len(recalled)) + " of the stored facts scored against this question"})
549 }
550 reply, used, srcs, widgets, stats, err := s.engine.Run(ctx, history, prompt, session, memoryBlock(recalled), tr, emit)
551 // Whether the turn worked or not, whatever it spent has been spent, and a
552 // failed turn is exactly when the counts matter most.
553 s.engine.SaveSpend(s.store.SaveSpend)
554 if err != nil {
555 emit(Event{Kind: "error", Text: err.Error()})
556 return
557 }
558
559 // Whatever the model wrote, the stored copy has no leaked markup in it.
560 reply.Content, _ = salvageCalls(reply.Content, func(string) bool { return false })
561
562 summaries := make([]ToolSummary, 0, len(used))
563 for _, u := range used {
564 summaries = append(summaries, ToolSummary{
565 Name: u.Name, Args: shortArgs(string(u.Args)),
566 MS: u.Elapsed.Milliseconds(), OK: u.Err == "", Err: u.Err,
567 Age: snapshotAge(u.Content),
568 })
569 }
570
571 convID := req.ConvID
572 // The bar shows this. Without it a conversation named on its first turn
573 // keeps saying "New conversation" until the tab navigates away and back.
574 title := ""
575 if !req.Incognito {
576 if convID == "" {
577 if id, e := s.store.NewConversation(""); e == nil {
578 convID = id
579 // The run was keyed by the id the browser made up. Move it, so
580 // a tab reopening this conversation by its real id finds the
581 // turn that is still writing into it.
582 s.runs.Rekey(key, convID)
583 // The browser made the old key up, so a tab that was told a
584 // turn had started under it needs the real id to follow.
585 s.hub.Publish(HubEvent{Kind: "started", ConvID: convID})
586 }
587 }
588 if convID != "" {
589 user := Stored{Role: RoleUser, Content: prompt}
590 if len(parts) > 0 {
591 user.Display, user.Files = req.Message, attachments(parts)
592 }
593 _ = s.store.Append(convID, user)
594 _ = s.store.Append(convID, Stored{Role: RoleAssistant, Content: reply.Content,
595 Tools: summaries, Sources: srcs, Widgets: widgets, Steps: tr.Steps()})
596 if len(stored) == 0 {
597 seed := titleSeed(req.Message, parts)
598 titleStart := time.Now()
599 if t := s.comp.Title(context.WithoutCancel(ctx), seed, reply.Content); t != "" {
600 title = t
601 _ = s.store.SetTitle(convID, t)
602 tr.Add(Step{Kind: "title", Label: "named the conversation",
603 In: seed, Out: t, MS: time.Since(titleStart).Milliseconds()})
604 }
605 }
606 s.store.Checkpoint()
607 }
608 }
609
610 // After the answer is on its way, never in front of it. Incognito is
611 // excluded: a mode that writes nothing down cannot be the one that teaches
612 // it something to write down later.
613 if !req.Incognito {
614 go func() {
615 // Recovered here and not by the middleware, which only wraps the
616 // handler. A panic on this goroutine would take the process down
617 // and lose every conversation in flight.
618 defer func() {
619 if r := recover(); r != nil {
620 slog.Error("the memory pass panicked", "err", r)
621 }
622 }()
623 bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
624 defer cancel()
625 s.Remember(bg, req.Message, reply.Content)
626 }()
627 }
628
629 done := map[string]any{"kind": "done", "conversation_id": convID, "title": title,
630 "tools": summaries, "html": s.renderCited(reply.Content, srcs), "incognito": req.Incognito,
631 "steps": tr.Steps(),
632 "sources": srcs,
633 "files": attachments(parts),
634 "stats": map[string]any{
635 "prompt_tokens": stats.Prompt, "completion_tokens": stats.Completion,
636 "decode_tps": round1(stats.Decode), "prefill_tps": round1(stats.Prefill),
637 "ctx": s.ctxSize,
638 }}
639 rn.Emit(done)
640 if !req.Incognito && convID != "" {
641 s.hub.Publish(HubEvent{Kind: "finished", ConvID: convID, Title: title})
642 }
643}
644
645// attach lets a tab that went away pick a turn back up. It is the same stream
646// send writes, from the beginning, so a browser that missed the first half sees
647// it replayed and then follows the rest.
648func (s *site) attach(w http.ResponseWriter, r *http.Request) {
649 id := r.PathValue("id")
650 rn, ok := s.runs.Get(id)
651 if !ok {
652 // Nothing running and nothing kept. The conversation endpoint has the
653 // messages, so this is not an error, there is just nothing to follow.
654 http.Error(w, "no turn to attach to", http.StatusNotFound)
655 return
656 }
657 s.streamRun(w, r, rn)
658}
659
660// stop cancels a turn. A browser that has stopped listening is not a reason to
661// stop the work, so this is the only thing that is.
662func (s *site) stop(w http.ResponseWriter, r *http.Request) {
663 writeJSON(w, map[string]any{"stopped": s.runs.Cancel(r.PathValue("id"))})
664}
665
666// waitingLabel says where in the queue a turn is, in words rather than a
667// number on its own, since "2" beside a spinner reads as an error code.
668func waitingLabel(q QueueState) string {
669 switch {
670 case q.Ahead <= 0:
671 return "waiting for the card"
672 case q.Ahead == 1:
673 return "waiting, one turn ahead"
674 default:
675 return "waiting, " + itoa(q.Ahead) + " turns ahead"
676 }
677}
678
679// render turns the model's markdown into HTML on the server, so the browser
680// never has to parse markdown and the sanitising happens in one place.
681func (s *site) render(md string) string {
682 var sb strings.Builder
683 if err := s.md.Convert([]byte(md), &sb); err != nil {
684 return "<p>" + template.HTMLEscapeString(md) + "</p>"
685 }
686 return sb.String()
687}
688
689// renderCited is the same render with the citation numbers turned into links.
690// The markdown is stored with its numbers rather than its anchors, so a change
691// to how a pill looks does not need every old message rewritten.
692func (s *site) renderCited(md string, srcs []Source) string {
693 return linkCitations(s.render(md), srcs)
694}
695
696func (s *site) listConversations(w http.ResponseWriter, r *http.Request) {
697 convs, err := s.store.List(60)
698 if err != nil {
699 http.Error(w, err.Error(), 500)
700 return
701 }
702 writeJSON(w, map[string]any{"conversations": convs})
703}
704
705func (s *site) getConversation(w http.ResponseWriter, r *http.Request) {
706 id := r.PathValue("id")
707 msgs, err := s.store.Messages(id)
708 if err != nil {
709 http.Error(w, err.Error(), 500)
710 return
711 }
712 type out struct {
713 Role Role `json:"role"`
714 HTML string `json:"html"`
715 Text string `json:"text"`
716 Files []Attachment `json:"files,omitempty"`
717 Tools []ToolSummary `json:"tools,omitempty"`
718 Sources []Source `json:"sources,omitempty"`
719 Widgets []Widget `json:"widgets,omitempty"`
720 Steps []Step `json:"steps,omitempty"`
721 }
722 rendered := make([]out, 0, len(msgs))
723 for _, m := range msgs {
724 o := out{Role: m.Role, Text: m.Shown(), Files: m.Files, Tools: m.Tools,
725 Sources: m.Sources, Widgets: m.Widgets, Steps: m.Steps}
726 if m.Role == RoleAssistant {
727 o.HTML = s.renderCited(m.Content, m.Sources)
728 }
729 rendered = append(rendered, o)
730 }
731 conv, _ := s.store.Get(id)
732 // Whether a turn is still writing into this conversation, so a tab that
733 // comes back knows to attach and follow rather than render what is stored
734 // and stop, which is how a half finished turn looked like a lost one.
735 running := false
736 if rn, ok := s.runs.Get(id); ok {
737 running = rn.Running()
738 }
739 writeJSON(w, map[string]any{"id": id, "title": conv.Title,
740 "messages": rendered, "running": running})
741}
742
743func (s *site) deleteConversation(w http.ResponseWriter, r *http.Request) {
744 id := r.PathValue("id")
745 if err := s.store.Delete(id); err != nil {
746 http.Error(w, err.Error(), 500)
747 return
748 }
749 s.hub.Publish(HubEvent{Kind: "changed", ConvID: id})
750 writeJSON(w, map[string]any{"deleted": id})
751}
752
753func (s *site) deleteAll(w http.ResponseWriter, r *http.Request) {
754 if err := s.store.DeleteAll(); err != nil {
755 http.Error(w, err.Error(), 500)
756 return
757 }
758 s.hub.Publish(HubEvent{Kind: "changed"})
759 writeJSON(w, map[string]any{"deleted": "all"})
760}
761
762func (s *site) status(w http.ResponseWriter, r *http.Request) {
763 nConv, nMsg := s.store.Count()
764 out := map[string]any{
765 "model": s.label, "up": s.llm.Healthy(r.Context()), "ctx": s.ctxSize,
766 "tools": tools.Default().Names(), "conversations": nConv, "messages": nMsg,
767 }
768 // Search being unavailable is the one tool failure worth saying out loud,
769 // because a turn without it answers from memory and reads like an ordinary
770 // answer. Everything else is narrow enough to report itself in the turn.
771 if left, down := s.engine.SearchDown(); down {
772 out["search_down"] = true
773 out["search_back_in"] = left.String()
774 }
775 minute, hour, day := s.engine.SearchSpend()
776 out["search_left"] = map[string]int{"minute": minute, "hour": hour, "day": day}
777 writeJSON(w, out)
778}
779
780func writeJSON(w http.ResponseWriter, v any) {
781 w.Header().Set("Content-Type", "application/json")
782 _ = json.NewEncoder(w).Encode(v)
783}
784
785// kfmt matches the meter in the app, which divides by 1024 because a context
786// window is a power of two and 65536 has to read as the 64k it was set to.
787func kfmt(n int) string {
788 if n < 1024 {
789 return strconv.Itoa(n)
790 }
791 return strconv.Itoa(n/1024) + "k"
792}
793
794// snapshotAge reads the date a tool's data was taken, when it has one. Any tool
795// answering from a snapshot rather than the live source reports it the same
796// way, so the chip needs no per tool knowledge.
797func snapshotAge(content any) string {
798 m, ok := content.(map[string]any)
799 if !ok {
800 return ""
801 }
802 s, _ := m["snapshot_date"].(string)
803 return s
804}