feat(chat): auto-generate conversation titles via lightweight LLM call

After the first agent run completes, generate a short title from the
user's message using a 10s/50-token LLM call. Title is stored in the
session label column and pushed to clients via session.updated event.
This commit is contained in:
viettranx committed 2026-03-19 23:34:45 +07:00
1 parent 24ad97af31
commit 4da85913a2
7 files changed
+94 -4

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
router := server.Router()
// Phase 1: Core methods
methods.NewChatMethods(agents, sessStore, server.RateLimiter()).Register(router)
methods.NewChatMethods(agents, sessStore, server.RateLimiter(), msgBus).Register(router)
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
methods.NewSessionsMethods(sessStore, msgBus).Register(router)
methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router)
+47
View File
@@ -0,0 +1,47 @@
package agent
import (
"context"
"log/slog"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
const titleGenerateTimeout = 10 * time.Second
const titleSystemPrompt = `Generate a short title (max 15 words) for this conversation based on the user's message. Reply with only the title, no quotes or punctuation wrapping.`
// GenerateTitle uses a lightweight LLM call to create a short conversation title
// from the user's first message. Returns empty string on error.
func GenerateTitle(ctx context.Context, provider providers.Provider, model, userMessage string) string {
ctx, cancel := context.WithTimeout(ctx, titleGenerateTimeout)
defer cancel()
resp, err := provider.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
{Role: "system", Content: titleSystemPrompt},
{Role: "user", Content: userMessage},
},
Model: model,
Options: map[string]any{
providers.OptMaxTokens: 50,
providers.OptTemperature: 0.3,
},
})
if err != nil {
slog.Warn("title generation failed", "error", err)
return ""
}
title := strings.TrimSpace(resp.Content)
// Strip surrounding quotes if present.
title = strings.Trim(title, "\"'`")
title = strings.TrimSpace(title)
if runes := []rune(title); len(runes) > 100 {
title = string(runes[:100])
}
return title
}
+6 -1
View File
@@ -1,6 +1,10 @@
package agent
import "context"
import (
"context"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// Agent is the core abstraction for an AI agent execution loop.
// Implemented by *Loop; extracted as an interface for testability and composability.
@@ -10,4 +14,5 @@ type Agent interface {
IsRunning() bool
Model() string
ProviderName() string
Provider() providers.Provider
}
+27 -2
View File
@@ -6,6 +6,8 @@ import (
"github.com/google/uuid"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
@@ -22,10 +24,11 @@ type ChatMethods struct {
agents *agent.Router
sessions store.SessionStore
rateLimiter *gateway.RateLimiter
eventBus bus.EventPublisher
}
func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter) *ChatMethods {
return &ChatMethods{agents: agents, sessions: sess, rateLimiter: rl}
func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods {
return &ChatMethods{agents: agents, sessions: sess, rateLimiter: rl, eventBus: eventBus}
}
// Register adds chat methods to the router.
@@ -206,6 +209,28 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
return
}
// Auto-generate conversation title on first message (label empty = never titled).
if label := m.sessions.GetLabel(sessionKey); label == "" {
agentProvider := loop.Provider()
agentModel := loop.Model()
userMsg := params.Message
go func() {
title := agent.GenerateTitle(context.Background(), agentProvider, agentModel, userMsg)
if title == "" {
return
}
m.sessions.SetLabel(sessionKey, title)
if err := m.sessions.Save(sessionKey); err != nil {
slog.Warn("failed to save session title", "sessionKey", sessionKey, "error", err)
return
}
m.eventBus.Broadcast(bus.Event{
Name: protocol.EventSessionUpdated,
Payload: map[string]string{"sessionKey": sessionKey, "label": title},
})
}()
}
resp := map[string]any{
"runId": result.RunID,
"content": result.Content,
+9
View File
@@ -159,6 +159,15 @@ func (s *PGSessionStore) SetSummary(key, summary string) {
}
}
func (s *PGSessionStore) GetLabel(key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[key]; ok {
return data.Label
}
return ""
}
func (s *PGSessionStore) SetLabel(key, label string) {
s.mu.Lock()
defer s.mu.Unlock()
+1
View File
@@ -91,6 +91,7 @@ type SessionStore interface {
GetHistory(key string) []providers.Message
GetSummary(key string) string
SetSummary(key, summary string)
GetLabel(key string) string
SetLabel(key, label string)
SetAgentInfo(key string, agentUUID uuid.UUID, userID string)
UpdateMetadata(key, model, provider, channel string)
+3
View File
@@ -88,6 +88,9 @@ const (
// Audit log event (internal, not forwarded to WS clients).
EventAuditLog = "audit.log"
// Session lifecycle events.
EventSessionUpdated = "session.updated"
// Zalo Personal QR login events (client-scoped, not broadcast).
EventZaloPersonalQRCode = "zalo.personal.qr.code"
EventZaloPersonalQRDone = "zalo.personal.qr.done"