Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
217 lines
5.5 KiB
Go
217 lines
5.5 KiB
Go
// Package session keeps one plan and its follow-up conversation per browser,
|
|
// in memory.
|
|
package session
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.b0b.be/bdeb/workout-suggester/internal/coach"
|
|
"git.b0b.be/bdeb/workout-suggester/internal/llm"
|
|
)
|
|
|
|
// State tracks an assistant reply through its lifecycle.
|
|
type State int
|
|
|
|
const (
|
|
Pending State = iota // created, nobody is generating it yet
|
|
Streaming // a stream handler owns it
|
|
Done // finished (successfully or with Err set)
|
|
)
|
|
|
|
// Message is a chat turn.
|
|
type Message struct {
|
|
ID string
|
|
Role string // "user" or "assistant"
|
|
Content string
|
|
State State
|
|
Err string
|
|
Hidden bool // the app's own question, not shown in the UI
|
|
}
|
|
|
|
// Conversation is one plan plus the chat about it.
|
|
type Conversation struct {
|
|
mu sync.Mutex
|
|
plan coach.Plan
|
|
messages []*Message
|
|
lastUsed time.Time
|
|
}
|
|
|
|
// Store maps session ids to conversations.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
convs map[string]*Conversation
|
|
}
|
|
|
|
// NewStore returns an empty store.
|
|
func NewStore() *Store {
|
|
return &Store{convs: make(map[string]*Conversation)}
|
|
}
|
|
|
|
// NewID returns a random hex id, suitable for sessions and messages.
|
|
func NewID() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// Get returns the conversation for a session, or nil if there is none.
|
|
func (s *Store) Get(session string) *Conversation {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
c := s.convs[session]
|
|
if c != nil {
|
|
c.mu.Lock()
|
|
c.lastUsed = time.Now()
|
|
c.mu.Unlock()
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Start replaces the session's conversation with a fresh plan and returns
|
|
// the pending first reply.
|
|
func (s *Store) Start(session string, p coach.Plan) (*Conversation, Message) {
|
|
c := &Conversation{plan: p, lastUsed: time.Now()}
|
|
q := &Message{ID: NewID(), Role: "user", Content: coach.FirstQuestion, State: Done, Hidden: true}
|
|
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
|
|
c.messages = []*Message{q, r}
|
|
s.mu.Lock()
|
|
s.convs[session] = c
|
|
s.mu.Unlock()
|
|
return c, *r
|
|
}
|
|
|
|
// Prune drops conversations idle for longer than maxIdle.
|
|
func (s *Store) Prune(maxIdle time.Duration) {
|
|
cutoff := time.Now().Add(-maxIdle)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for id, c := range s.convs {
|
|
c.mu.Lock()
|
|
idle := c.lastUsed.Before(cutoff)
|
|
c.mu.Unlock()
|
|
if idle {
|
|
delete(s.convs, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Plan returns the conversation's plan.
|
|
func (c *Conversation) Plan() coach.Plan {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.plan
|
|
}
|
|
|
|
// UpdatePlan changes the plan, e.g. once routes have been looked up.
|
|
func (c *Conversation) UpdatePlan(f func(*coach.Plan)) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
f(&c.plan)
|
|
}
|
|
|
|
// Messages returns a snapshot of the visible conversation.
|
|
func (c *Conversation) Messages() []Message {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
var out []Message
|
|
for _, m := range c.messages {
|
|
if !m.Hidden {
|
|
out = append(out, *m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Ask appends a user message plus a pending assistant reply and returns both.
|
|
func (c *Conversation) Ask(text string) (user, reply Message) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
u := &Message{ID: NewID(), Role: "user", Content: text, State: Done}
|
|
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
|
|
c.messages = append(c.messages, u, r)
|
|
return *u, *r
|
|
}
|
|
|
|
// maxHistory bounds how many earlier turns go back to the model; small
|
|
// models lose the thread (and speed) with long contexts.
|
|
const maxHistory = 12
|
|
|
|
// Claim marks a pending reply as streaming and returns the prompt to send:
|
|
// the plan's system prompt plus the finished messages before the reply. ok
|
|
// is false if the reply does not exist or someone else already claimed it;
|
|
// msg then holds its current state (if it exists).
|
|
func (c *Conversation) Claim(id string) (prompt []llm.Message, msg Message, ok bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
idx := c.index(id)
|
|
if idx < 0 {
|
|
return nil, Message{}, false
|
|
}
|
|
m := c.messages[idx]
|
|
if m.State != Pending {
|
|
return nil, *m, false
|
|
}
|
|
m.State = Streaming
|
|
|
|
var history []llm.Message
|
|
for _, h := range c.messages[:idx] {
|
|
if h.State == Done && h.Err == "" && h.Content != "" {
|
|
history = append(history, llm.Message{Role: h.Role, Content: h.Content})
|
|
}
|
|
}
|
|
if len(history) > maxHistory { // keep the opening question and plan
|
|
history = append(history[:2:2], history[len(history)-maxHistory+2:]...)
|
|
}
|
|
// Small models tend to repeat the whole plan template; nudge follow-ups.
|
|
if n := len(history); n > 2 && history[n-1].Role == "user" {
|
|
history[n-1].Content += "\n\n(" + coach.FollowUpHint + ")"
|
|
}
|
|
prompt = []llm.Message{{Role: "system", Content: c.plan.SystemPrompt()}}
|
|
return append(prompt, history...), *m, true
|
|
}
|
|
|
|
// IsFirstReply reports whether id is the plan's opening reply.
|
|
func (c *Conversation) IsFirstReply(id string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.messages) > 1 && c.messages[1].ID == id
|
|
}
|
|
|
|
// Append adds streamed text to a reply.
|
|
func (c *Conversation) Append(id, delta string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if i := c.index(id); i >= 0 {
|
|
c.messages[i].Content += delta
|
|
}
|
|
}
|
|
|
|
// Finish marks a reply as done, recording err if generation failed, and
|
|
// returns its final state.
|
|
func (c *Conversation) Finish(id string, err error) Message {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
i := c.index(id)
|
|
if i < 0 {
|
|
return Message{ID: id, Role: "assistant", State: Done}
|
|
}
|
|
m := c.messages[i]
|
|
m.State = Done
|
|
if err != nil {
|
|
m.Err = err.Error()
|
|
}
|
|
return *m
|
|
}
|
|
|
|
func (c *Conversation) index(id string) int {
|
|
for i, m := range c.messages {
|
|
if m.ID == id {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|