diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 323d9b92..7d40d754 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -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) diff --git a/internal/agent/title_generate.go b/internal/agent/title_generate.go new file mode 100644 index 00000000..261e1336 --- /dev/null +++ b/internal/agent/title_generate.go @@ -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 +} diff --git a/internal/agent/types.go b/internal/agent/types.go index 0ed2929a..e39ca186 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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 } diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index 3b43c974..8bb3013b 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -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, diff --git a/internal/store/pg/sessions.go b/internal/store/pg/sessions.go index d33d2ed1..d7a53730 100644 --- a/internal/store/pg/sessions.go +++ b/internal/store/pg/sessions.go @@ -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() diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 1fcf5b6a..51074d49 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -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) diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index 953615bc..08a6fe1c 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -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"