diff --git a/cmd/gateway.go b/cmd/gateway.go index 129cc0d0..74c46a20 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -696,6 +696,11 @@ func runGateway() { // Register channels/instances/links/teams RPC methods wireChannelRPCMethods(server, pgStores, channelMgr, agentRouter, msgBus) + // Register party mode WS RPC methods + if pgStores.Party != nil { + methods.NewPartyMethods(pgStores.Party, pgStores.Agents, providerRegistry, msgBus).Register(server.Router()) + } + // Wire channel event subscribers (cache invalidation, pairing, cascade disable) wireChannelEventSubscribers(msgBus, server, pgStores, channelMgr, instanceLoader, pairingMethods, cfg) diff --git a/cmd/gateway_consumer_process.go b/cmd/gateway_consumer_process.go index f67143ef..4b5f5439 100644 --- a/cmd/gateway_consumer_process.go +++ b/cmd/gateway_consumer_process.go @@ -14,10 +14,20 @@ import ( // It extracts the agentID from the session key and routes to the correct agent loop. func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.RunFunc { return func(ctx context.Context, req agent.RunRequest) (*agent.RunResult, error) { - // Extract agentID from session key (format: agent:{agentId}:{rest}) + // Extract agentID from session key. + // Supported formats: + // agent:{agentId}:{rest} + // delegate:{sourceUUID8}:{targetAgentKey}:{delegationId} agentID := cfg.ResolveDefaultAgentID() - if parts := strings.SplitN(req.SessionKey, ":", 3); len(parts) >= 2 && parts[0] == "agent" { - agentID = parts[1] + if parts := strings.SplitN(req.SessionKey, ":", 4); len(parts) >= 2 { + switch parts[0] { + case "agent": + agentID = parts[1] + case "delegate": + if len(parts) >= 3 { + agentID = parts[2] + } + } } loop, err := agents.Get(agentID) diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index e26438a6..716ff671 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -151,7 +151,18 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s // History pipeline matching TS: limitHistoryTurns → pruneContext → sanitizeHistory. trimmed := limitHistoryTurns(history, historyLimit) pruned := pruneContextMessages(trimmed, l.contextWindow, l.contextPruningCfg) - messages = append(messages, sanitizeHistory(pruned)...) + sanitized, droppedCount := sanitizeHistory(pruned) + messages = append(messages, sanitized...) + + // If orphaned messages were found and dropped, persist the cleaned history + // back to the session store so the same orphans don't trigger on every request. + if droppedCount > 0 { + slog.Info("sanitizeHistory: cleaned session history", + "session", sessionKey, "dropped", droppedCount) + cleanedHistory, _ := sanitizeHistory(history) + l.sessions.SetHistory(sessionKey, cleanedHistory) + l.sessions.Save(sessionKey) + } // Current user message messages = append(messages, providers.Message{ @@ -270,21 +281,26 @@ func limitHistoryTurns(msgs []providers.Message, limit int) []providers.Message // - Orphaned tool messages at start of history (after truncation) // - tool_result without matching tool_use in preceding assistant message // - assistant with tool_calls but missing tool_results -func sanitizeHistory(msgs []providers.Message) []providers.Message { +// +// Returns the cleaned messages and the number of messages that were dropped or synthesized. +func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { if len(msgs) == 0 { - return msgs + return msgs, 0 } + dropped := 0 + // 1. Skip leading orphaned tool messages (no preceding assistant with tool_calls). start := 0 for start < len(msgs) && msgs[start].Role == "tool" { - slog.Warn("dropping orphaned tool message at history start", + slog.Debug("sanitizeHistory: dropping orphaned tool message at history start", "tool_call_id", msgs[start].ToolCallID) start++ + dropped++ } if start >= len(msgs) { - return nil + return nil, dropped } // 2. Walk through messages ensuring tool_result follows matching tool_use. @@ -309,30 +325,33 @@ func sanitizeHistory(msgs []providers.Message) []providers.Message { result = append(result, toolMsg) delete(expectedIDs, toolMsg.ToolCallID) } else { - slog.Warn("dropping mismatched tool result", + slog.Debug("sanitizeHistory: dropping mismatched tool result", "tool_call_id", toolMsg.ToolCallID) + dropped++ } } // Synthesize missing tool results for id := range expectedIDs { - slog.Warn("synthesizing missing tool result", "tool_call_id", id) + slog.Debug("sanitizeHistory: synthesizing missing tool result", "tool_call_id", id) result = append(result, providers.Message{ Role: "tool", Content: "[Tool result missing — session was compacted]", ToolCallID: id, }) + dropped++ } } else if msg.Role == "tool" { // Orphaned tool message mid-history (no preceding assistant with matching tool_calls) - slog.Warn("dropping orphaned tool message mid-history", + slog.Debug("sanitizeHistory: dropping orphaned tool message mid-history", "tool_call_id", msg.ToolCallID) + dropped++ } else { result = append(result, msg) } } - return result + return result, dropped } func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { diff --git a/internal/agent/memoryflush.go b/internal/agent/memoryflush.go index 00c38456..2806f571 100644 --- a/internal/agent/memoryflush.go +++ b/internal/agent/memoryflush.go @@ -155,7 +155,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings * if len(recentHistory) > 10 { recentHistory = recentHistory[len(recentHistory)-10:] } - messages = append(messages, sanitizeHistory(recentHistory)...) + sanitized, _ := sanitizeHistory(recentHistory) + messages = append(messages, sanitized...) // Flush prompt messages = append(messages, providers.Message{ diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index c727e50e..92382a47 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -13,6 +13,8 @@ import ( "io" "log/slog" "net/http" + "os" + "path/filepath" "strings" "sync" "time" @@ -88,7 +90,7 @@ func (c *Channel) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("zalo getMe failed: %w", err) } - slog.Info("zalo bot connected", "bot_id", info.ID, "bot_name", info.Name) + slog.Info("zalo bot connected", "bot_id", info.ID, "bot_name", info.Label()) c.SetRunning(true) @@ -231,14 +233,33 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) { content = "[image]" } + // Download photo from Zalo CDN to local temp file (CDN URLs are auth-restricted/expiring) var media []string - if msg.Photo != "" { - media = []string{msg.Photo} + var photoURL string + switch { + case msg.PhotoURL != "": + photoURL = msg.PhotoURL + case msg.Photo != "": + photoURL = msg.Photo } - slog.Debug("zalo image message received", + if photoURL != "" { + localPath, err := c.downloadMedia(photoURL) + if err != nil { + slog.Warn("zalo photo download failed, passing URL as fallback", + "photo_url", photoURL, "error", err) + media = []string{photoURL} + } else { + media = []string{localPath} + } + } + + slog.Info("zalo image message received", "sender_id", senderID, "chat_id", chatID, + "photo_url", photoURL, + "has_media", len(media) > 0, + "downloaded", len(media) > 0 && !strings.HasPrefix(media[0], "http"), ) metadata := map[string]string{ @@ -316,6 +337,55 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } +// --- Media download --- + +const maxMediaBytes = 10 * 1024 * 1024 // 10MB + +// downloadMedia fetches a photo from a Zalo CDN URL and saves it as a local temp file. +func (c *Channel) downloadMedia(url string) (string, error) { + resp, err := c.client.Get(url) + if err != nil { + return "", fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("http %d", resp.StatusCode) + } + + // Detect extension from Content-Type + ext := ".jpg" + ct := resp.Header.Get("Content-Type") + switch { + case strings.Contains(ct, "png"): + ext = ".png" + case strings.Contains(ct, "gif"): + ext = ".gif" + case strings.Contains(ct, "webp"): + ext = ".webp" + } + + f, err := os.CreateTemp("", "goclaw_zalo_*"+ext) + if err != nil { + return "", fmt.Errorf("create temp: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, io.LimitReader(resp.Body, maxMediaBytes)) + if err != nil { + os.Remove(f.Name()) + return "", fmt.Errorf("write: %w", err) + } + + slog.Debug("zalo media downloaded", + "url", url[:min(len(url), 80)], + "path", filepath.Base(f.Name()), + "bytes", n, + ) + + return f.Name(), nil +} + // --- Chunked text sending --- func (c *Channel) sendChunkedText(chatID, text string) error { @@ -350,28 +420,41 @@ type zaloAPIResponse struct { } type zaloBotInfo struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"account_name"` + DisplayName string `json:"display_name"` +} + +func (b *zaloBotInfo) Label() string { + if b.DisplayName != "" { + return b.DisplayName + } + return b.Name } type zaloMessage struct { - MessageID string `json:"message_id"` - Text string `json:"text"` - Photo string `json:"photo"` - Caption string `json:"caption"` - From zaloFrom `json:"from"` - Chat zaloChat `json:"chat"` - Date int64 `json:"date"` + MessageID string `json:"message_id"` + MessageType string `json:"message_type"` + Text string `json:"text"` + Photo string `json:"photo"` + PhotoURL string `json:"photo_url"` + Caption string `json:"caption"` + From zaloFrom `json:"from"` + Chat zaloChat `json:"chat"` + Date int64 `json:"date"` } type zaloFrom struct { - ID string `json:"id"` - Username string `json:"username"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + IsBot bool `json:"is_bot"` } type zaloChat struct { - ID string `json:"id"` - Type string `json:"type"` + ID string `json:"id"` + Type string `json:"type"` + ChatType string `json:"chat_type"` } type zaloUpdate struct { @@ -445,11 +528,28 @@ func (c *Channel) getUpdates(timeout int) ([]zaloUpdate, error) { return nil, err } + // Try array first var updates []zaloUpdate - if err := json.Unmarshal(result, &updates); err != nil { + if err := json.Unmarshal(result, &updates); err == nil { + return updates, nil + } + + // Try single object (Zalo Bot Platform returns one update at a time) + var single zaloUpdate + if err := json.Unmarshal(result, &single); err == nil && single.EventName != "" { + slog.Info("zalo update received", "event", single.EventName) + return []zaloUpdate{single}, nil + } + + // Try wrapped {"updates": [...]} + var wrapped struct { + Updates []zaloUpdate `json:"updates"` + } + if err := json.Unmarshal(result, &wrapped); err != nil { + slog.Warn("zalo getUpdates unknown format", "raw", string(result[:min(len(result), 500)])) return nil, fmt.Errorf("unmarshal updates: %w", err) } - return updates, nil + return wrapped.Updates, nil } func (c *Channel) sendMessage(chatID, text string) error { diff --git a/internal/gateway/methods/party.go b/internal/gateway/methods/party.go new file mode 100644 index 00000000..af048e74 --- /dev/null +++ b/internal/gateway/methods/party.go @@ -0,0 +1,540 @@ +package methods + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/party" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// PartyMethods handles party.* WebSocket RPC methods. +type PartyMethods struct { + partyStore store.PartyStore + agentStore store.AgentStore + providerReg *providers.Registry + msgBus *bus.MessageBus +} + +// NewPartyMethods creates a new PartyMethods handler. +func NewPartyMethods(partyStore store.PartyStore, agentStore store.AgentStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *PartyMethods { + return &PartyMethods{ + partyStore: partyStore, + agentStore: agentStore, + providerReg: providerReg, + msgBus: msgBus, + } +} + +// Register registers all party.* methods on the router. +func (m *PartyMethods) Register(router *gateway.MethodRouter) { + router.Register(protocol.MethodPartyStart, m.handleStart) + router.Register(protocol.MethodPartyRound, m.handleRound) + router.Register(protocol.MethodPartyQuestion, m.handleQuestion) + router.Register(protocol.MethodPartyAddContext, m.handleAddContext) + router.Register(protocol.MethodPartySummary, m.handleSummary) + router.Register(protocol.MethodPartyExit, m.handleExit) + router.Register(protocol.MethodPartyList, m.handleList) +} + +// getEngine returns a party engine using the first available provider. +func (m *PartyMethods) getEngine() (*party.Engine, error) { + names := m.providerReg.List() + if len(names) == 0 { + return nil, fmt.Errorf("no LLM providers available") + } + provider, err := m.providerReg.Get(names[0]) + if err != nil { + return nil, fmt.Errorf("provider %s: %w", names[0], err) + } + return party.NewEngine(m.partyStore, m.agentStore, provider), nil +} + +// emitterForClient creates an EventEmitter that broadcasts to all connected WS clients. +func (m *PartyMethods) emitterForClient(client *gateway.Client) party.EventEmitter { + return func(event protocol.EventFrame) { + client.SendEvent(event) + } +} + +type partyStartParams struct { + Topic string `json:"topic"` + TeamPreset string `json:"team_preset,omitempty"` + Personas []string `json:"personas,omitempty"` + ContextURLs []string `json:"context_urls,omitempty"` +} + +func (m *PartyMethods) handleStart(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params partyStartParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.Topic == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "topic is required")) + return + } + + // Resolve personas from preset or custom list + personaKeys := params.Personas + if params.TeamPreset != "" { + for _, preset := range party.PresetTeams() { + if preset.Key == params.TeamPreset { + personaKeys = preset.Personas + break + } + } + } + if len(personaKeys) == 0 { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no personas selected")) + return + } + + engine, err := m.getEngine() + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + // Load persona info from DB + personas, err := engine.LoadPersonas(ctx, personaKeys) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + // Marshal persona keys for storage + personasJSON, _ := json.Marshal(personaKeys) + + // Create session + sess := &store.PartySessionData{ + Topic: params.Topic, + TeamPreset: params.TeamPreset, + Status: store.PartyStatusDiscussing, + Mode: store.PartyModeStandard, + MaxRounds: 10, + UserID: client.UserID(), + Personas: personasJSON, + } + if err := m.partyStore.CreateSession(ctx, sess); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + slog.Info("party: session started", "session_id", sess.ID, "topic", params.Topic, "personas", len(personas)) + + // Emit started event + emit := m.emitterForClient(client) + personaInfos := make([]map[string]string, len(personas)) + for i, p := range personas { + personaInfos[i] = map[string]string{ + "agent_key": p.AgentKey, + "display_name": p.DisplayName, + "emoji": p.Emoji, + "movie_ref": p.MovieRef, + } + } + emit(*protocol.NewEvent(protocol.EventPartyStarted, map[string]any{ + "session_id": sess.ID, + "topic": params.Topic, + "personas": personaInfos, + })) + + // Generate introductions for each persona + for _, p := range personas { + intro := fmt.Sprintf("%s %s reporting in. Ready to discuss: %s", p.Emoji, p.DisplayName, params.Topic) + emit(*protocol.NewEvent(protocol.EventPartyPersonaIntro, map[string]any{ + "session_id": sess.ID, + "persona": p.AgentKey, + "emoji": p.Emoji, + "content": intro, + })) + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "personas": personaInfos, + "status": sess.Status, + })) +} + +type partyRoundParams struct { + SessionID string `json:"session_id"` + Mode string `json:"mode,omitempty"` +} + +func (m *PartyMethods) handleRound(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params partyRoundParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + sessID, err := uuid.Parse(params.SessionID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id")) + return + } + + sess, err := m.partyStore.GetSession(ctx, sessID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found")) + return + } + + if sess.Status != store.PartyStatusDiscussing { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "session is not in discussing state")) + return + } + + // Increment round + sess.Round++ + mode := params.Mode + if mode == "" { + mode = sess.Mode + } + + engine, err := m.getEngine() + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + // Load personas + var personaKeys []string + json.Unmarshal(sess.Personas, &personaKeys) + personas, err := engine.LoadPersonas(ctx, personaKeys) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + emit := m.emitterForClient(client) + emit(*protocol.NewEvent(protocol.EventPartyRoundStarted, map[string]any{ + "session_id": sess.ID, + "round": sess.Round, + "mode": mode, + })) + + // Run the round + var result *party.RoundResult + switch mode { + case store.PartyModeDeep: + result, err = engine.RunDeepRound(ctx, sess, personas, emit) + case store.PartyModeTokenRing: + result, err = engine.RunTokenRingRound(ctx, sess, personas, emit) + default: + result, err = engine.RunStandardRound(ctx, sess, personas, emit) + } + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + // Append to history + var history []party.RoundResult + json.Unmarshal(sess.History, &history) + history = append(history, *result) + historyJSON, _ := json.Marshal(history) + + // Update session + if err := m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{ + "round": sess.Round, + "mode": mode, + "history": historyJSON, + }); err != nil { + slog.Warn("party: failed to update session", "error", err) + } + + emit(*protocol.NewEvent(protocol.EventPartyRoundComplete, map[string]any{ + "session_id": sess.ID, + "round": sess.Round, + "mode": mode, + })) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "round": sess.Round, + "mode": mode, + "messages": result.Messages, + })) +} + +type partyQuestionParams struct { + SessionID string `json:"session_id"` + Text string `json:"text"` +} + +func (m *PartyMethods) handleQuestion(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params partyQuestionParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + sessID, err := uuid.Parse(params.SessionID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id")) + return + } + + sess, err := m.partyStore.GetSession(ctx, sessID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found")) + return + } + + // Temporarily set topic to the question for this round + originalTopic := sess.Topic + sess.Topic = fmt.Sprintf("%s\n\nUser question: %s", originalTopic, params.Text) + sess.Round++ + + engine, err := m.getEngine() + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + var personaKeys []string + json.Unmarshal(sess.Personas, &personaKeys) + personas, err := engine.LoadPersonas(ctx, personaKeys) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + emit := m.emitterForClient(client) + result, err := engine.RunStandardRound(ctx, sess, personas, emit) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + // Restore topic and update session + sess.Topic = originalTopic + var history []party.RoundResult + json.Unmarshal(sess.History, &history) + history = append(history, *result) + historyJSON, _ := json.Marshal(history) + + if err := m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{ + "round": sess.Round, + "history": historyJSON, + }); err != nil { + slog.Warn("party: failed to update session", "error", err) + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "round": sess.Round, + "messages": result.Messages, + })) +} + +type partyAddContextParams struct { + SessionID string `json:"session_id"` + Type string `json:"type"` + Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + URL string `json:"url,omitempty"` +} + +func (m *PartyMethods) handleAddContext(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params partyAddContextParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + sessID, err := uuid.Parse(params.SessionID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id")) + return + } + + sess, err := m.partyStore.GetSession(ctx, sessID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found")) + return + } + + // Parse existing context + var sessionCtx map[string]any + if json.Unmarshal(sess.Context, &sessionCtx) != nil { + sessionCtx = make(map[string]any) + } + + // Add new context based on type + switch params.Type { + case "document": + docs, _ := sessionCtx["documents"].([]any) + docs = append(docs, map[string]string{"name": params.Name, "content": params.Content, "source": "upload"}) + sessionCtx["documents"] = docs + case "meeting_notes": + sessionCtx["meeting_notes"] = params.Content + case "custom": + sessionCtx["custom"] = params.Content + default: + sessionCtx[params.Type] = params.Content + } + + contextJSON, _ := json.Marshal(sessionCtx) + if err := m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{ + "context": contextJSON, + }); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + emit := m.emitterForClient(client) + emit(*protocol.NewEvent(protocol.EventPartyContextAdded, map[string]any{ + "session_id": sess.ID, + "name": params.Name, + "type": params.Type, + })) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "ok": true, + "context_count": len(sessionCtx), + })) +} + +func (m *PartyMethods) handleSummary(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + sessID, err := uuid.Parse(params.SessionID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id")) + return + } + + sess, err := m.partyStore.GetSession(ctx, sessID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found")) + return + } + + engine, err := m.getEngine() + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + summary, err := engine.GenerateSummary(ctx, sess) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + // Store summary in session + summaryJSON, _ := json.Marshal(summary) + m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{ + "summary": summaryJSON, + "status": store.PartyStatusSummarizing, + }) + + emit := m.emitterForClient(client) + emit(*protocol.NewEvent(protocol.EventPartySummaryReady, map[string]any{ + "session_id": sess.ID, + "summary": summary, + })) + + client.SendResponse(protocol.NewOKResponse(req.ID, summary)) +} + +func (m *PartyMethods) handleExit(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + SessionID string `json:"session_id"` + FollowUp string `json:"follow_up,omitempty"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + sessID, err := uuid.Parse(params.SessionID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id")) + return + } + + sess, err := m.partyStore.GetSession(ctx, sessID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found")) + return + } + + // Generate summary if not yet done + var summary *party.SummaryResult + if sess.Summary == nil || string(sess.Summary) == "null" { + engine, err := m.getEngine() + if err == nil { + summary, _ = engine.GenerateSummary(ctx, sess) + } + } else { + json.Unmarshal(sess.Summary, &summary) + } + + // Close session + updates := map[string]any{"status": store.PartyStatusClosed} + if summary != nil { + summaryJSON, _ := json.Marshal(summary) + updates["summary"] = summaryJSON + } + m.partyStore.UpdateSession(ctx, sess.ID, updates) + + emit := m.emitterForClient(client) + emit(*protocol.NewEvent(protocol.EventPartyClosed, map[string]any{ + "session_id": sess.ID, + })) + + response := map[string]any{ + "session_id": sess.ID, + "status": store.PartyStatusClosed, + } + if summary != nil { + response["summary"] = summary + } + + client.SendResponse(protocol.NewOKResponse(req.ID, response)) +} + +func (m *PartyMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + Status string `json:"status,omitempty"` + Limit int `json:"limit,omitempty"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + limit := params.Limit + if limit <= 0 { + limit = 20 + } + + sessions, err := m.partyStore.ListSessions(ctx, client.UserID(), params.Status, limit) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "sessions": sessions, + "count": len(sessions), + })) +} diff --git a/internal/party/engine.go b/internal/party/engine.go new file mode 100644 index 00000000..3362524e --- /dev/null +++ b/internal/party/engine.go @@ -0,0 +1,307 @@ +package party + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// EventEmitter sends party events to connected clients. +type EventEmitter func(event protocol.EventFrame) + +// Engine orchestrates party mode discussions. +type Engine struct { + partyStore store.PartyStore + agentStore store.AgentStore + provider providers.Provider +} + +// NewEngine creates a new party engine. +func NewEngine(partyStore store.PartyStore, agentStore store.AgentStore, provider providers.Provider) *Engine { + return &Engine{ + partyStore: partyStore, + agentStore: agentStore, + provider: provider, + } +} + +// LoadPersonas loads persona info from agent DB for the given keys. +func (e *Engine) LoadPersonas(ctx context.Context, keys []string) ([]PersonaInfo, error) { + var personas []PersonaInfo + for _, key := range keys { + agent, err := e.agentStore.GetByKey(ctx, key) + if err != nil { + return nil, fmt.Errorf("persona %s not found: %w", key, err) + } + pi := PersonaInfo{ + AgentKey: key, + DisplayName: agent.DisplayName, + } + // Extract persona metadata from other_config + var cfg map[string]json.RawMessage + if json.Unmarshal(agent.OtherConfig, &cfg) == nil { + if personaJSON, ok := cfg["persona"]; ok { + var pm struct { + Emoji string `json:"emoji"` + MovieRef string `json:"movie_ref"` + SpeakingStyle string `json:"speaking_style"` + ExpertiseWeight map[string]float64 `json:"expertise_weight"` + } + if json.Unmarshal(personaJSON, &pm) == nil { + pi.Emoji = pm.Emoji + pi.MovieRef = pm.MovieRef + pi.SpeakingStyle = pm.SpeakingStyle + pi.ExpertiseWeight = pm.ExpertiseWeight + } + } + } + if pi.Emoji == "" { + pi.Emoji = "🤖" + } + personas = append(personas, pi) + } + return personas, nil +} + +// RunStandardRound executes a standard mode round (1 LLM call, all personas). +func (e *Engine) RunStandardRound(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) (*RoundResult, error) { + slog.Info("party: standard round", "session", session.ID, "round", session.Round) + + systemPrompt := "You are a party mode facilitator. Generate responses for each persona in character." + userPrompt := BuildStandardRoundPrompt(session, personas) + + resp, err := e.llmCall(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, fmt.Errorf("standard round LLM call: %w", err) + } + + messages := parsePersonaMessages(resp, personas) + for _, m := range messages { + emit(*protocol.NewEvent(protocol.EventPartyPersonaSpoke, map[string]any{ + "session_id": session.ID, "persona": m.PersonaKey, + "emoji": m.Emoji, "content": m.Content, + })) + } + + return &RoundResult{Round: session.Round, Mode: store.PartyModeStandard, Messages: messages}, nil +} + +// RunDeepRound executes Deep Mode: parallel thinking → cross-talk. +func (e *Engine) RunDeepRound(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) (*RoundResult, error) { + slog.Info("party: deep round (parallel)", "session", session.ID, "round", session.Round, "personas", len(personas)) + + // Step 1: Parallel thinking + thoughts, err := e.runParallelThinking(ctx, session, personas, emit) + if err != nil { + return nil, fmt.Errorf("parallel thinking: %w", err) + } + + // Step 2: Cross-talk (1 LLM call) + systemPrompt := "You are a party mode facilitator generating cross-talk between personas." + userPrompt := BuildCrossTalkPrompt(session, personas, thoughts) + + resp, err := e.llmCall(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, fmt.Errorf("cross-talk LLM call: %w", err) + } + + messages := parsePersonaMessages(resp, personas) + for i := range messages { + // Attach thinking from Step 1 + for _, t := range thoughts { + if t.PersonaKey == messages[i].PersonaKey { + messages[i].Thinking = t.Content + break + } + } + emit(*protocol.NewEvent(protocol.EventPartyPersonaSpoke, map[string]any{ + "session_id": session.ID, "persona": messages[i].PersonaKey, + "emoji": messages[i].Emoji, "content": messages[i].Content, + })) + } + + return &RoundResult{Round: session.Round, Mode: store.PartyModeDeep, Messages: messages}, nil +} + +// RunTokenRingRound executes Token-Ring: parallel thinking → sequential turns. +func (e *Engine) RunTokenRingRound(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) (*RoundResult, error) { + slog.Info("party: token-ring round", "session", session.ID, "round", session.Round, "personas", len(personas)) + + // Step 1: Parallel thinking + thoughts, err := e.runParallelThinking(ctx, session, personas, emit) + if err != nil { + return nil, fmt.Errorf("parallel thinking: %w", err) + } + + // Step 2: Sequential turns + var messages []PersonaMessage + var priorTurns []PersonaMessage + + for i, persona := range personas { + isLast := i == len(personas)-1 + + soulMD := e.loadPersonaSoulMD(ctx, persona.AgentKey) + systemPrompt := BuildPersonaSystemPrompt(persona, session, soulMD) + userPrompt := BuildTokenRingTurnPrompt(session, persona, thoughts, priorTurns, isLast) + + resp, err := e.llmCall(ctx, systemPrompt, userPrompt) + if err != nil { + slog.Warn("party: token-ring turn failed", "persona", persona.AgentKey, "error", err) + continue + } + + msg := PersonaMessage{ + PersonaKey: persona.AgentKey, + DisplayName: persona.DisplayName, + Emoji: persona.Emoji, + Content: strings.TrimSpace(resp), + } + messages = append(messages, msg) + priorTurns = append(priorTurns, msg) + + // Emit immediately — user sees each persona respond in real-time + emit(*protocol.NewEvent(protocol.EventPartyPersonaSpoke, map[string]any{ + "session_id": session.ID, "persona": msg.PersonaKey, + "emoji": msg.Emoji, "content": msg.Content, + })) + } + + return &RoundResult{Round: session.Round, Mode: store.PartyModeTokenRing, Messages: messages}, nil +} + +// runParallelThinking executes independent thinking for all personas in parallel. +func (e *Engine) runParallelThinking(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) ([]PersonaThought, error) { + thoughts := make([]PersonaThought, len(personas)) + errs := make([]error, len(personas)) + var wg sync.WaitGroup + + for i, persona := range personas { + wg.Add(1) + go func(idx int, p PersonaInfo) { + defer wg.Done() + + emit(*protocol.NewEvent(protocol.EventPartyPersonaThinking, map[string]any{ + "session_id": session.ID, "persona": p.AgentKey, "emoji": p.Emoji, + })) + + soulMD := e.loadPersonaSoulMD(ctx, p.AgentKey) + systemPrompt := BuildPersonaSystemPrompt(p, session, soulMD) + userPrompt := BuildThinkingPrompt(session, p) + + resp, err := e.llmCall(ctx, systemPrompt, userPrompt) + if err != nil { + errs[idx] = err + return + } + thoughts[idx] = PersonaThought{PersonaKey: p.AgentKey, Emoji: p.Emoji, Content: strings.TrimSpace(resp)} + }(i, persona) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + return nil, fmt.Errorf("persona %s thinking failed: %w", personas[i].AgentKey, err) + } + } + + return thoughts, nil +} + +// GenerateSummary generates a discussion summary. +func (e *Engine) GenerateSummary(ctx context.Context, session *store.PartySessionData) (*SummaryResult, error) { + prompt := BuildSummaryPrompt(session) + resp, err := e.llmCall(ctx, "You are a discussion summarizer. Generate structured markdown summaries.", prompt) + if err != nil { + return nil, fmt.Errorf("summary LLM call: %w", err) + } + + var personaKeys []string + json.Unmarshal(session.Personas, &personaKeys) + + return &SummaryResult{ + Topic: session.Topic, + Rounds: session.Round, + Personas: personaKeys, + Markdown: resp, + }, nil +} + +func (e *Engine) llmCall(ctx context.Context, systemPrompt, userPrompt string) (string, error) { + req := providers.ChatRequest{ + Messages: []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + Options: map[string]any{ + "max_tokens": 4096, + }, + } + resp, err := e.provider.Chat(ctx, req) + if err != nil { + return "", err + } + return resp.Content, nil +} + +func (e *Engine) loadPersonaSoulMD(ctx context.Context, agentKey string) string { + agent, err := e.agentStore.GetByKey(ctx, agentKey) + if err != nil { + return "" + } + files, _ := e.agentStore.GetAgentContextFiles(ctx, agent.ID) + for _, f := range files { + if f.FileName == "SOUL.md" { + return f.Content + } + } + return "" +} + +// parsePersonaMessages parses LLM output into individual persona messages. +func parsePersonaMessages(resp string, personas []PersonaInfo) []PersonaMessage { + var messages []PersonaMessage + lines := strings.Split(resp, "\n") + + var current *PersonaMessage + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + matched := false + for _, p := range personas { + if strings.HasPrefix(line, p.Emoji) { + if current != nil { + messages = append(messages, *current) + } + content := line + prefix := p.Emoji + " " + p.DisplayName + ":" + if strings.HasPrefix(line, prefix) { + content = strings.TrimSpace(line[len(prefix):]) + } + current = &PersonaMessage{ + PersonaKey: p.AgentKey, + DisplayName: p.DisplayName, + Emoji: p.Emoji, + Content: content, + } + matched = true + break + } + } + if !matched && current != nil { + current.Content += "\n" + line + } + } + if current != nil { + messages = append(messages, *current) + } + return messages +} diff --git a/internal/party/prompt.go b/internal/party/prompt.go new file mode 100644 index 00000000..7217e92b --- /dev/null +++ b/internal/party/prompt.go @@ -0,0 +1,161 @@ +package party + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// BuildPersonaSystemPrompt builds the system prompt for a persona in a party round. +func BuildPersonaSystemPrompt(persona PersonaInfo, session *store.PartySessionData, soulMD string) string { + var sb strings.Builder + + // Persona identity + sb.WriteString(soulMD) + sb.WriteString("\n\n") + + // Party context + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf("%s\n", session.Topic)) + + var ctx map[string]json.RawMessage + if json.Unmarshal(session.Context, &ctx) == nil { + if docs, ok := ctx["documents"]; ok { + sb.WriteString("\n") + sb.Write(docs) + sb.WriteString("\n\n") + } + if code, ok := ctx["codebase"]; ok { + sb.WriteString("\n") + sb.Write(code) + sb.WriteString("\n\n") + } + if notes, ok := ctx["meeting_notes"]; ok { + sb.WriteString("\n") + sb.Write(notes) + sb.WriteString("\n\n") + } + if custom, ok := ctx["custom"]; ok { + sb.WriteString("\n") + sb.Write(custom) + sb.WriteString("\n\n") + } + } + sb.WriteString("\n\n") + + // Round history (sliding window — last 3 rounds) + var history []RoundResult + if json.Unmarshal(session.History, &history) == nil && len(history) > 0 { + start := 0 + if len(history) > 3 { + start = len(history) - 3 + } + sb.WriteString("\n") + for _, r := range history[start:] { + sb.WriteString(fmt.Sprintf("Round %d [%s]:\n", r.Round, r.Mode)) + for _, m := range r.Messages { + sb.WriteString(fmt.Sprintf(" %s %s: %s\n", m.Emoji, m.DisplayName, truncate(m.Content, 500))) + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +// BuildStandardRoundPrompt builds the user message for a standard round. +func BuildStandardRoundPrompt(session *store.PartySessionData, personas []PersonaInfo) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Round %d discussion about: %s\n\n", session.Round, session.Topic)) + sb.WriteString("Respond as each of these personas IN CHARACTER. Each persona gives their expert analysis.\n") + sb.WriteString("Format each response as: {emoji} {name}: {response}\n") + sb.WriteString("Encourage genuine disagreement where expertise conflicts.\n\n") + sb.WriteString("Personas:\n") + for _, p := range personas { + sb.WriteString(fmt.Sprintf("- %s %s (%s)\n", p.Emoji, p.DisplayName, p.SpeakingStyle)) + } + return sb.String() +} + +// BuildThinkingPrompt builds the user message for Deep Mode Step 1 (independent thinking). +func BuildThinkingPrompt(session *store.PartySessionData, persona PersonaInfo) string { + return fmt.Sprintf( + "Round %d: Think independently about \"%s\".\n"+ + "Share your analysis from your %s expertise.\n"+ + "Be specific, cite relevant standards/principles.\n"+ + "Identify risks, opportunities, and trade-offs.\n"+ + "Stay completely in character as %s.", + session.Round, session.Topic, persona.DisplayName, persona.DisplayName) +} + +// BuildCrossTalkPrompt builds the prompt for Deep Mode Step 2 (cross-talk from collected thoughts). +func BuildCrossTalkPrompt(session *store.PartySessionData, personas []PersonaInfo, thoughts []PersonaThought) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Round %d cross-talk about: %s\n\n", session.Round, session.Topic)) + sb.WriteString("Each persona has shared their independent thinking below.\n") + sb.WriteString("Now generate cross-talk: personas respond to EACH OTHER.\n") + sb.WriteString("Challenge disagreements explicitly. Build on agreements.\n") + sb.WriteString("Stay in character. Format: {emoji} {name}: {response}\n\n") + + sb.WriteString("\n") + for _, t := range thoughts { + sb.WriteString(fmt.Sprintf("<%s>\n%s\n\n", t.PersonaKey, t.Content, t.PersonaKey)) + } + sb.WriteString("\n") + return sb.String() +} + +// BuildTokenRingTurnPrompt builds the prompt for one persona's turn in Token-Ring mode. +func BuildTokenRingTurnPrompt(session *store.PartySessionData, persona PersonaInfo, thoughts []PersonaThought, priorTurns []PersonaMessage, isLast bool) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Round %d, your turn in the discussion about: %s\n\n", session.Round, session.Topic)) + + sb.WriteString("Independent thoughts from all personas:\n") + for _, t := range thoughts { + sb.WriteString(fmt.Sprintf("- %s: %s\n", t.PersonaKey, truncate(t.Content, 300))) + } + + if len(priorTurns) > 0 { + sb.WriteString("\nPrior responses this round:\n") + for _, m := range priorTurns { + sb.WriteString(fmt.Sprintf(" %s %s: %s\n", m.Emoji, m.DisplayName, m.Content)) + } + sb.WriteString("\nRespond to what others have said. Challenge or build on their points.\n") + } + + if isLast { + sb.WriteString("\nYou are the LAST speaker. Synthesize: what does the team agree on? What remains unresolved?\n") + } + + sb.WriteString("\nStay completely in character. Be direct and specific.") + return sb.String() +} + +// BuildSummaryPrompt builds the prompt for generating the discussion summary. +func BuildSummaryPrompt(session *store.PartySessionData) string { + return fmt.Sprintf(`Summarize this party mode discussion. + +Topic: %s +Rounds: %d + +Discussion history: +%s + +Generate a structured summary with: +1. Points of Agreement (unanimous decisions) +2. Points of Disagreement (who disagrees, why) +3. Decisions Made +4. Action Items (action, assignee persona, deadline suggestion, checkpoint link) +5. Compliance Notes (if any security/PCI-DSS/SBV items) + +Format as clean markdown.`, session.Topic, session.Round, string(session.History)) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/party/types.go b/internal/party/types.go new file mode 100644 index 00000000..933d0f97 --- /dev/null +++ b/internal/party/types.go @@ -0,0 +1,77 @@ +package party + +// PersonaInfo holds runtime persona metadata loaded from agent DB. +type PersonaInfo struct { + AgentKey string `json:"agent_key"` + DisplayName string `json:"display_name"` + Emoji string `json:"emoji"` + MovieRef string `json:"movie_ref"` + SpeakingStyle string `json:"speaking_style"` + ExpertiseWeight map[string]float64 `json:"expertise_weight,omitempty"` +} + +// PersonaThought is one persona's independent thinking output (Deep Mode Step 1). +type PersonaThought struct { + PersonaKey string `json:"persona_key"` + Emoji string `json:"emoji"` + Content string `json:"content"` +} + +// PersonaMessage is a persona's spoken message in a round. +type PersonaMessage struct { + PersonaKey string `json:"persona_key"` + DisplayName string `json:"display_name"` + Emoji string `json:"emoji"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` +} + +// RoundResult contains all persona messages for one round. +type RoundResult struct { + Round int `json:"round"` + Mode string `json:"mode"` + Messages []PersonaMessage `json:"messages"` +} + +// SummaryResult contains the party discussion summary. +type SummaryResult struct { + Topic string `json:"topic"` + Rounds int `json:"rounds"` + Personas []string `json:"personas"` + Agreements []string `json:"agreements"` + Disagreements []string `json:"disagreements"` + Decisions []string `json:"decisions"` + ActionItems []ActionItem `json:"action_items"` + Compliance []string `json:"compliance_notes,omitempty"` + Markdown string `json:"markdown"` +} + +// ActionItem is a follow-up task from the discussion. +type ActionItem struct { + Action string `json:"action"` + Assignee string `json:"assignee"` + Deadline string `json:"deadline,omitempty"` + CPLink string `json:"cp_link,omitempty"` +} + +// PresetTeam defines a preset team composition. +type PresetTeam struct { + Key string `json:"key"` + Name string `json:"name"` + Personas []string `json:"personas"` + UseCase string `json:"use_case"` + Facilitator string `json:"facilitator"` + Mandatory []string `json:"mandatory,omitempty"` +} + +// PresetTeams returns the 6 preset team compositions. +func PresetTeams() []PresetTeam { + return []PresetTeam{ + {Key: "payment_feature", Name: "Payment Feature", Personas: []string{"tony-stark-persona", "neo-persona", "batman-persona", "judge-dredd-persona", "columbo-persona"}, UseCase: "Payment flows, settlement", Facilitator: "gandalf-persona"}, + {Key: "security_review", Name: "Security Review", Personas: []string{"batman-persona", "judge-dredd-persona", "neo-persona", "scotty-persona"}, UseCase: "Threat modeling, pre-CP3", Facilitator: "batman-persona"}, + {Key: "sprint_planning", Name: "Sprint Planning", Personas: []string{"tony-stark-persona", "sherlock-persona", "neo-persona", "gandalf-persona", "columbo-persona"}, UseCase: "Sprint kickoff, PRD review", Facilitator: "gandalf-persona"}, + {Key: "architecture_decision", Name: "Architecture Decision", Personas: []string{"neo-persona", "spock-persona", "scotty-persona", "batman-persona"}, UseCase: "ADR, tech stack eval", Facilitator: "morpheus-persona"}, + {Key: "ux_review", Name: "UX Review", Personas: []string{"edna-mode-persona", "tony-stark-persona", "spider-man-persona", "ethan-hunt-persona", "columbo-persona"}, UseCase: "Design review", Facilitator: "edna-mode-persona"}, + {Key: "incident_response", Name: "Incident Response", Personas: []string{"scotty-persona", "neo-persona", "batman-persona", "nick-fury-persona"}, UseCase: "Production incidents", Facilitator: "nick-fury-persona"}, + } +} diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 24765a6a..b8d89b62 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -260,6 +260,19 @@ func (m *Manager) GetLastPromptTokens(key string) (int, int) { return 0, 0 } +// SetHistory replaces the full message history for a session. +func (m *Manager) SetHistory(key string, msgs []providers.Message) { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[key] + if !ok { + return + } + s.Messages = msgs + s.Updated = time.Now() +} + // TruncateHistory keeps only the last N messages. func (m *Manager) TruncateHistory(key string, keepLast int) { m.mu.Lock() diff --git a/internal/store/party_store.go b/internal/store/party_store.go new file mode 100644 index 00000000..31e0b80e --- /dev/null +++ b/internal/store/party_store.go @@ -0,0 +1,56 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// Party session statuses. +const ( + PartyStatusAssembling = "assembling" + PartyStatusDiscussing = "discussing" + PartyStatusSummarizing = "summarizing" + PartyStatusClosed = "closed" +) + +// Party discussion modes. +const ( + PartyModeStandard = "standard" + PartyModeDeep = "deep" + PartyModeTokenRing = "token_ring" +) + +// PartySessionData represents a party mode session. +type PartySessionData struct { + ID uuid.UUID `json:"id"` + Topic string `json:"topic"` + TeamPreset string `json:"team_preset,omitempty"` + Status string `json:"status"` + Mode string `json:"mode"` + Round int `json:"round"` + MaxRounds int `json:"max_rounds"` + UserID string `json:"user_id"` + Channel string `json:"channel,omitempty"` + ChatID string `json:"chat_id,omitempty"` + Personas json.RawMessage `json:"personas"` + Context json.RawMessage `json:"context"` + History json.RawMessage `json:"history"` + Summary json.RawMessage `json:"summary,omitempty"` + Artifacts json.RawMessage `json:"artifacts"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PartyStore manages party mode sessions. +type PartyStore interface { + CreateSession(ctx context.Context, session *PartySessionData) error + GetSession(ctx context.Context, id uuid.UUID) (*PartySessionData, error) + UpdateSession(ctx context.Context, id uuid.UUID, updates map[string]any) error + ListSessions(ctx context.Context, userID string, status string, limit int) ([]*PartySessionData, error) + // GetActiveSession returns the active (assembling/discussing) session for a user+channel+chat. + GetActiveSession(ctx context.Context, userID, channel, chatID string) (*PartySessionData, error) + DeleteSession(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go index 236d2278..fc235cdb 100644 --- a/internal/store/pg/channel_instances.go +++ b/internal/store/pg/channel_instances.go @@ -145,25 +145,28 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI } func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { - // Encrypt credentials if present + // Ensure credentials is always []byte for the bytea column if credsVal, ok := updates["credentials"]; ok && credsVal != nil { - var credsStr string + var credsBytes []byte switch v := credsVal.(type) { + case []byte: + credsBytes = v case string: - credsStr = v + credsBytes = []byte(v) default: - // Object/map from JSON — marshal to string for encryption + // Object/map from JSON — marshal to []byte if b, err := json.Marshal(v); err == nil { - credsStr = string(b) + credsBytes = b } } - if credsStr != "" && s.encKey != "" { - encrypted, err := crypto.Encrypt(credsStr, s.encKey) + if len(credsBytes) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey) if err != nil { return fmt.Errorf("encrypt credentials: %w", err) } - updates["credentials"] = []byte(encrypted) + credsBytes = []byte(encrypted) } + updates["credentials"] = credsBytes } updates["updated_at"] = time.Now() return execMapUpdate(ctx, s.db, "channel_instances", id, updates) diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 7b27fc4f..b0ed9de4 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -41,5 +41,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { BuiltinTools: NewPGBuiltinToolStore(db), PendingMessages: NewPGPendingMessageStore(db), KnowledgeGraph: NewPGKnowledgeGraphStore(db), + Party: NewPGPartyStore(db), }, nil } diff --git a/internal/store/pg/party.go b/internal/store/pg/party.go new file mode 100644 index 00000000..ecb597db --- /dev/null +++ b/internal/store/pg/party.go @@ -0,0 +1,140 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const partySelectCols = `id, topic, team_preset, status, mode, round, max_rounds, + user_id, channel, chat_id, personas, context, history, + COALESCE(summary, 'null'), artifacts, created_at, updated_at` + +// PGPartyStore implements PartyStore backed by PostgreSQL. +type PGPartyStore struct { + db *sql.DB +} + +// NewPGPartyStore creates a new PGPartyStore. +func NewPGPartyStore(db *sql.DB) *PGPartyStore { + return &PGPartyStore{db: db} +} + +func (s *PGPartyStore) CreateSession(ctx context.Context, sess *store.PartySessionData) error { + if sess.ID == uuid.Nil { + sess.ID = store.GenNewID() + } + now := time.Now() + sess.CreatedAt = now + sess.UpdatedAt = now + if len(sess.Personas) == 0 { + sess.Personas = json.RawMessage("[]") + } + if len(sess.Context) == 0 { + sess.Context = json.RawMessage("{}") + } + if len(sess.History) == 0 { + sess.History = json.RawMessage("[]") + } + if len(sess.Artifacts) == 0 { + sess.Artifacts = json.RawMessage("[]") + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO party_sessions + (id, topic, team_preset, status, mode, round, max_rounds, + user_id, channel, chat_id, personas, context, history, artifacts, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + sess.ID, sess.Topic, sess.TeamPreset, sess.Status, sess.Mode, + sess.Round, sess.MaxRounds, sess.UserID, sess.Channel, sess.ChatID, + sess.Personas, sess.Context, sess.History, sess.Artifacts, + sess.CreatedAt, sess.UpdatedAt) + return err +} + +func (s *PGPartyStore) GetSession(ctx context.Context, id uuid.UUID) (*store.PartySessionData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+partySelectCols+` FROM party_sessions WHERE id = $1`, id) + return scanPartyRow(row) +} + +func (s *PGPartyStore) GetActiveSession(ctx context.Context, userID, channel, chatID string) (*store.PartySessionData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+partySelectCols+` FROM party_sessions + WHERE user_id = $1 AND channel = $2 AND chat_id = $3 + AND status IN ('assembling', 'discussing') + ORDER BY created_at DESC LIMIT 1`, userID, channel, chatID) + sess, err := scanPartyRow(row) + if err == sql.ErrNoRows { + return nil, nil + } + return sess, err +} + +func (s *PGPartyStore) UpdateSession(ctx context.Context, id uuid.UUID, updates map[string]any) error { + updates["updated_at"] = time.Now() + return execMapUpdate(ctx, s.db, "party_sessions", id, updates) +} + +func (s *PGPartyStore) ListSessions(ctx context.Context, userID string, status string, limit int) ([]*store.PartySessionData, error) { + query := `SELECT ` + partySelectCols + ` FROM party_sessions WHERE user_id = $1` + args := []any{userID} + if status != "" { + query += ` AND status = $2` + args = append(args, status) + } + query += ` ORDER BY created_at DESC` + if limit > 0 { + query += fmt.Sprintf(` LIMIT %d`, limit) + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []*store.PartySessionData + for rows.Next() { + sess, err := scanPartyRows(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, sess) + } + return sessions, rows.Err() +} + +func (s *PGPartyStore) DeleteSession(ctx context.Context, id uuid.UUID) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM party_sessions WHERE id = $1`, id) + return err +} + +func scanPartyRow(row *sql.Row) (*store.PartySessionData, error) { + var s store.PartySessionData + err := row.Scan(&s.ID, &s.Topic, &s.TeamPreset, &s.Status, &s.Mode, + &s.Round, &s.MaxRounds, &s.UserID, &s.Channel, &s.ChatID, + &s.Personas, &s.Context, &s.History, &s.Summary, &s.Artifacts, + &s.CreatedAt, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} + +func scanPartyRows(rows *sql.Rows) (*store.PartySessionData, error) { + var s store.PartySessionData + err := rows.Scan(&s.ID, &s.Topic, &s.TeamPreset, &s.Status, &s.Mode, + &s.Round, &s.MaxRounds, &s.UserID, &s.Channel, &s.ChatID, + &s.Personas, &s.Context, &s.History, &s.Summary, &s.Artifacts, + &s.CreatedAt, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} diff --git a/internal/store/pg/sessions_ops.go b/internal/store/pg/sessions_ops.go index 95dee2e1..29e466a4 100644 --- a/internal/store/pg/sessions_ops.go +++ b/internal/store/pg/sessions_ops.go @@ -6,6 +6,15 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/providers" ) +func (s *PGSessionStore) SetHistory(key string, msgs []providers.Message) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.Messages = msgs + data.Updated = time.Now() + } +} + func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 460e2ee8..99787c71 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -99,6 +99,7 @@ type SessionStore interface { GetContextWindow(key string) int SetLastPromptTokens(key string, tokens, msgCount int) GetLastPromptTokens(key string) (tokens, msgCount int) + SetHistory(key string, msgs []providers.Message) TruncateHistory(key string, keepLast int) Reset(key string) Delete(key string) error diff --git a/internal/store/stores.go b/internal/store/stores.go index f3611cb7..4e0be54f 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -22,4 +22,5 @@ type Stores struct { BuiltinTools BuiltinToolStore PendingMessages PendingMessageStore KnowledgeGraph KnowledgeGraphStore + Party PartyStore } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index fcbe1811..554af766 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 13 +const RequiredSchemaVersion uint = 14 diff --git a/migrations/000014_party_sessions.down.sql b/migrations/000014_party_sessions.down.sql new file mode 100644 index 00000000..4adc042c --- /dev/null +++ b/migrations/000014_party_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS party_sessions; diff --git a/migrations/000014_party_sessions.up.sql b/migrations/000014_party_sessions.up.sql new file mode 100644 index 00000000..17093083 --- /dev/null +++ b/migrations/000014_party_sessions.up.sql @@ -0,0 +1,23 @@ +-- 000014_party_sessions.up.sql +CREATE TABLE IF NOT EXISTS party_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + topic TEXT NOT NULL, + team_preset VARCHAR(50), + status VARCHAR(20) NOT NULL DEFAULT 'assembling', + mode VARCHAR(10) NOT NULL DEFAULT 'standard', + round INT NOT NULL DEFAULT 0, + max_rounds INT NOT NULL DEFAULT 10, + user_id VARCHAR(200) NOT NULL, + channel VARCHAR(255), + chat_id VARCHAR(200), + personas JSONB NOT NULL DEFAULT '[]', + context JSONB NOT NULL DEFAULT '{}', + history JSONB NOT NULL DEFAULT '[]', + summary JSONB, + artifacts JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_party_sessions_user ON party_sessions(user_id, status); +CREATE INDEX IF NOT EXISTS idx_party_sessions_channel ON party_sessions(channel, chat_id, status); diff --git a/pkg/protocol/party.go b/pkg/protocol/party.go new file mode 100644 index 00000000..b724e35f --- /dev/null +++ b/pkg/protocol/party.go @@ -0,0 +1,26 @@ +package protocol + +// Party Mode methods. +const ( + MethodPartyStart = "party.start" + MethodPartyRound = "party.round" + MethodPartyQuestion = "party.question" + MethodPartyAddContext = "party.add_context" + MethodPartySummary = "party.summary" + MethodPartyExit = "party.exit" + MethodPartyList = "party.list" +) + +// Party Mode events. +const ( + EventPartyStarted = "party.started" + EventPartyPersonaIntro = "party.persona.intro" + EventPartyRoundStarted = "party.round.started" + EventPartyPersonaThinking = "party.persona.thinking" + EventPartyPersonaSpoke = "party.persona.spoke" + EventPartyRoundComplete = "party.round.complete" + EventPartyContextAdded = "party.context.added" + EventPartySummaryReady = "party.summary.ready" + EventPartyArtifact = "party.artifact" + EventPartyClosed = "party.closed" +) diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index f6d1e1b2..04c5714f 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -141,6 +141,15 @@ export const Methods = { DELEGATIONS_LIST: "delegations.list", DELEGATIONS_GET: "delegations.get", + // Party mode + PARTY_START: "party.start", + PARTY_ROUND: "party.round", + PARTY_QUESTION: "party.question", + PARTY_ADD_CONTEXT: "party.add_context", + PARTY_SUMMARY: "party.summary", + PARTY_EXIT: "party.exit", + PARTY_LIST: "party.list", + // Phase 3+ - NICE TO HAVE LOGS_TAIL: "logs.tail", } as const; @@ -199,6 +208,18 @@ export const Events = { // Trace lifecycle TRACE_UPDATED: "trace.updated", + + // Party mode + PARTY_STARTED: "party.started", + PARTY_PERSONA_INTRO: "party.persona.intro", + PARTY_ROUND_STARTED: "party.round.started", + PARTY_PERSONA_THINKING: "party.persona.thinking", + PARTY_PERSONA_SPOKE: "party.persona.spoke", + PARTY_ROUND_COMPLETE: "party.round.complete", + PARTY_CONTEXT_ADDED: "party.context.added", + PARTY_SUMMARY_READY: "party.summary.ready", + PARTY_ARTIFACT: "party.artifact", + PARTY_CLOSED: "party.closed", } as const; /** All event names relevant to team debug view */ diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 4896797e..508e5ba0 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -23,6 +23,7 @@ import { HardDrive, Inbox, Brain, + PartyPopper, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; @@ -76,6 +77,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index d8f6d7cd..6e541606 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -30,6 +30,7 @@ import enSetup from "./locales/en/setup.json"; import enMemory from "./locales/en/memory.json"; import enStorage from "./locales/en/storage.json"; import enPendingMessages from "./locales/en/pending-messages.json"; +import enParty from "./locales/en/party.json"; // --- VI namespaces --- import viCommon from "./locales/vi/common.json"; @@ -60,6 +61,7 @@ import viSetup from "./locales/vi/setup.json"; import viMemory from "./locales/vi/memory.json"; import viStorage from "./locales/vi/storage.json"; import viPendingMessages from "./locales/vi/pending-messages.json"; +import viParty from "./locales/vi/party.json"; // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; @@ -90,6 +92,7 @@ import zhSetup from "./locales/zh/setup.json"; import zhMemory from "./locales/zh/memory.json"; import zhStorage from "./locales/zh/storage.json"; import zhPendingMessages from "./locales/zh/pending-messages.json"; +import zhParty from "./locales/zh/party.json"; const STORAGE_KEY = "goclaw:language"; @@ -107,7 +110,7 @@ const ns = [ "agents", "teams", "sessions", "skills", "cron", "config", "channels", "providers", "traces", "events", "delegations", "usage", "approvals", "nodes", "logs", "tools", "mcp", "tts", - "setup", "memory", "storage", "pending-messages", + "setup", "memory", "storage", "pending-messages", "party", ] as const; i18n.use(initReactI18next).init({ @@ -121,6 +124,7 @@ i18n.use(initReactI18next).init({ approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools, mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage, "pending-messages": enPendingMessages, + party: enParty, }, vi: { common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin, @@ -131,6 +135,7 @@ i18n.use(initReactI18next).init({ approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools, mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage, "pending-messages": viPendingMessages, + party: viParty, }, zh: { common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin, @@ -141,6 +146,7 @@ i18n.use(initReactI18next).init({ approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools, mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage, "pending-messages": zhPendingMessages, + party: zhParty, }, }, ns: [...ns], diff --git a/ui/web/src/i18n/locales/en/party.json b/ui/web/src/i18n/locales/en/party.json new file mode 100644 index 00000000..b4c6e0ab --- /dev/null +++ b/ui/web/src/i18n/locales/en/party.json @@ -0,0 +1,46 @@ +{ + "title": "Party Mode", + "newParty": "New Party", + "topic": "Discussion Topic", + "selectTeam": "Select Team", + "customTeam": "Custom Team", + "start": "Start Discussion", + "presets": { + "payment_feature": "Payment Feature", + "security_review": "Security Review", + "sprint_planning": "Sprint Planning", + "architecture_decision": "Architecture Decision", + "ux_review": "UX Review", + "incident_response": "Incident Response" + }, + "controls": { + "continue": "Continue", + "deepMode": "Deep Mode [P]", + "tokenRing": "Token Ring [R]", + "question": "Question [Q]", + "summary": "Summary [D]", + "exit": "Exit [E]" + }, + "status": { + "thinking": "Thinking...", + "speaking": "Speaking", + "idle": "Idle" + }, + "round": "Round {{n}}", + "mode": { + "standard": "Standard", + "deep": "Deep", + "token_ring": "Token Ring" + }, + "noSessions": "No party sessions yet", + "description": "Multi-persona AI discussions with structured rounds", + "exitConfirm": "Exit this party session?", + "summary": { + "title": "Discussion Summary", + "agreements": "Points of Agreement", + "disagreements": "Points of Disagreement", + "decisions": "Decisions Made", + "actionItems": "Action Items", + "compliance": "Compliance Notes" + } +} diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index c1f05979..0e4d437a 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -29,6 +29,7 @@ "config": "Config", "approvals": "Approvals", "nodes": "Nodes", - "tts": "TTS" + "tts": "TTS", + "party": "Party Mode" } } diff --git a/ui/web/src/i18n/locales/vi/party.json b/ui/web/src/i18n/locales/vi/party.json new file mode 100644 index 00000000..29de1f63 --- /dev/null +++ b/ui/web/src/i18n/locales/vi/party.json @@ -0,0 +1,46 @@ +{ + "title": "Ch\u1ebf \u0111\u1ed9 Party", + "newParty": "T\u1ea1o Party m\u1edbi", + "topic": "Ch\u1ee7 \u0111\u1ec1 th\u1ea3o lu\u1eadn", + "selectTeam": "Ch\u1ecdn \u0111\u1ed9i", + "customTeam": "\u0110\u1ed9i tu\u1ef3 ch\u1ec9nh", + "start": "B\u1eaft \u0111\u1ea7u th\u1ea3o lu\u1eadn", + "presets": { + "payment_feature": "T\u00ednh n\u0103ng Thanh to\u00e1n", + "security_review": "\u0110\u00e1nh gi\u00e1 B\u1ea3o m\u1eadt", + "sprint_planning": "L\u1eadp k\u1ebf ho\u1ea1ch Sprint", + "architecture_decision": "Quy\u1ebft \u0111\u1ecbnh Ki\u1ebfn tr\u00fac", + "ux_review": "\u0110\u00e1nh gi\u00e1 UX", + "incident_response": "X\u1eed l\u00fd s\u1ef1 c\u1ed1" + }, + "controls": { + "continue": "Ti\u1ebfp t\u1ee5c", + "deepMode": "Ch\u1ebf \u0111\u1ed9 Deep [P]", + "tokenRing": "Token Ring [R]", + "question": "C\u00e2u h\u1ecfi [Q]", + "summary": "T\u00f3m t\u1eaft [D]", + "exit": "Tho\u00e1t [E]" + }, + "status": { + "thinking": "\u0110ang suy ngh\u0129...", + "speaking": "\u0110ang n\u00f3i", + "idle": "Ch\u1edd" + }, + "round": "V\u00f2ng {{n}}", + "mode": { + "standard": "Ti\u00eau chu\u1ea9n", + "deep": "Deep", + "token_ring": "Token Ring" + }, + "noSessions": "Ch\u01b0a c\u00f3 phi\u00ean party n\u00e0o", + "description": "Th\u1ea3o lu\u1eadn AI \u0111a nh\u00e2n v\u1eadt v\u1edbi c\u00e1c v\u00f2ng c\u00f3 c\u1ea5u tr\u00fac", + "exitConfirm": "Tho\u00e1t phi\u00ean party n\u00e0y?", + "summary": { + "title": "T\u00f3m t\u1eaft th\u1ea3o lu\u1eadn", + "agreements": "\u0110i\u1ec3m \u0111\u1ed3ng thu\u1eadn", + "disagreements": "\u0110i\u1ec3m b\u1ea5t \u0111\u1ed3ng", + "decisions": "Quy\u1ebft \u0111\u1ecbnh", + "actionItems": "H\u1ea1ng m\u1ee5c h\u00e0nh \u0111\u1ed9ng", + "compliance": "Ghi ch\u00fa tu\u00e2n th\u1ee7" + } +} diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index 987da203..d8fb5656 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -29,6 +29,7 @@ "config": "Cấu hình", "approvals": "Phê duyệt", "nodes": "Nút", - "tts": "Chuyển văn bản thành giọng nói" + "tts": "Chuyển văn bản thành giọng nói", + "party": "Chế độ Party" } } diff --git a/ui/web/src/i18n/locales/zh/party.json b/ui/web/src/i18n/locales/zh/party.json new file mode 100644 index 00000000..92ff4e51 --- /dev/null +++ b/ui/web/src/i18n/locales/zh/party.json @@ -0,0 +1,46 @@ +{ + "title": "Party 模式", + "newParty": "新建 Party", + "topic": "讨论主题", + "selectTeam": "选择团队", + "customTeam": "自定义团队", + "start": "开始讨论", + "presets": { + "payment_feature": "支付功能", + "security_review": "安全审查", + "sprint_planning": "Sprint 规划", + "architecture_decision": "架构决策", + "ux_review": "UX 审查", + "incident_response": "事件响应" + }, + "controls": { + "continue": "继续", + "deepMode": "深度模式 [P]", + "tokenRing": "令牌环 [R]", + "question": "提问 [Q]", + "summary": "总结 [D]", + "exit": "退出 [E]" + }, + "status": { + "thinking": "思考中...", + "speaking": "发言中", + "idle": "空闲" + }, + "round": "第 {{n}} 轮", + "mode": { + "standard": "标准", + "deep": "深度", + "token_ring": "令牌环" + }, + "noSessions": "暂无 Party 会话", + "description": "多角色 AI 讨论,结构化轮次", + "exitConfirm": "退出此 Party 会话?", + "summary": { + "title": "讨论总结", + "agreements": "共识要点", + "disagreements": "分歧要点", + "decisions": "已做决策", + "actionItems": "行动项", + "compliance": "合规备注" + } +} diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index 346341bd..41384822 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -29,6 +29,7 @@ "storage": "存储", "traces": "追踪", "tts": "语音合成", - "usage": "用量" + "usage": "用量", + "party": "Party 模式" } } diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 6c4af913..1b93a26a 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -25,6 +25,7 @@ export const ROUTES = { PROVIDERS: "/providers", TEAMS: "/teams", TEAM_DETAIL: "/teams/:id", + PARTY: "/party", CUSTOM_TOOLS: "/custom-tools", BUILTIN_TOOLS: "/builtin-tools", MCP: "/mcp", diff --git a/ui/web/src/pages/party/hooks/use-party.ts b/ui/web/src/pages/party/hooks/use-party.ts new file mode 100644 index 00000000..4466655d --- /dev/null +++ b/ui/web/src/pages/party/hooks/use-party.ts @@ -0,0 +1,302 @@ +import { useState, useCallback, useRef } from "react"; +import { useWs } from "@/hooks/use-ws"; +import { useWsEvent } from "@/hooks/use-ws-event"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { Methods, Events } from "@/api/protocol"; + +// --- Types --- + +export type PartyMode = "standard" | "deep" | "token_ring"; + +export interface PersonaInfo { + key: string; + emoji: string; + name: string; + role: string; + color: string; +} + +export interface PartyMessage { + id: string; + type: "intro" | "spoke" | "thinking" | "round_header" | "context" | "summary" | "artifact"; + personaKey?: string; + personaEmoji?: string; + personaName?: string; + content: string; + round?: number; + mode?: PartyMode; + timestamp: number; +} + +export interface PartySession { + id: string; + topic: string; + status: "active" | "closed"; + personas: PersonaInfo[]; + round: number; + mode: PartyMode; + createdAt: string; +} + +export interface PartySummary { + agreements?: string[]; + disagreements?: string[]; + decisions?: string[]; + actionItems?: string[]; + compliance?: string[]; + markdown?: string; +} + +// Persona color palette for left-border styling +const PERSONA_COLORS = [ + "#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", + "#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6", + "#e11d48", "#84cc16", "#a855f7", "#0ea5e9", +]; + +export function useParty() { + const ws = useWs(); + const connected = useAuthStore((s) => s.connected); + + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [messages, setMessages] = useState([]); + const [personas, setPersonas] = useState([]); + const [thinkingPersonas, setThinkingPersonas] = useState>(new Set()); + const [round, setRound] = useState(0); + const [mode, setMode] = useState("standard"); + const [status, setStatus] = useState<"idle" | "active" | "closed">("idle"); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + + const msgIdCounter = useRef(0); + const personaColorMap = useRef>(new Map()); + + const getPersonaColor = useCallback((key: string): string => { + if (personaColorMap.current.has(key)) { + return personaColorMap.current.get(key)!; + } + const idx = personaColorMap.current.size % PERSONA_COLORS.length; + const color = PERSONA_COLORS[idx]; + personaColorMap.current.set(key, color); + return color; + }, []); + + const addMessage = useCallback((msg: Omit) => { + const id = `pm-${++msgIdCounter.current}`; + setMessages((prev) => [...prev, { ...msg, id, timestamp: Date.now() }]); + }, []); + + // --- Event handlers --- + + const handlePartyStarted = useCallback((payload: unknown) => { + const p = payload as { + sessionId: string; + topic: string; + personas: Array<{ key: string; emoji: string; name: string; role: string }>; + }; + setActiveSessionId(p.sessionId); + const enriched = p.personas.map((pe) => ({ + ...pe, + color: getPersonaColor(pe.key), + })); + setPersonas(enriched); + setRound(0); + setMode("standard"); + setStatus("active"); + setSummary(null); + setMessages([]); + personaColorMap.current.clear(); + enriched.forEach((pe) => personaColorMap.current.set(pe.key, pe.color)); + }, [getPersonaColor]); + + const handlePersonaIntro = useCallback((payload: unknown) => { + const p = payload as { personaKey: string; emoji: string; name: string; intro: string }; + addMessage({ + type: "intro", + personaKey: p.personaKey, + personaEmoji: p.emoji, + personaName: p.name, + content: p.intro, + }); + }, [addMessage]); + + const handleRoundStarted = useCallback((payload: unknown) => { + const p = payload as { round: number; mode: string }; + setRound(p.round); + setMode(p.mode as PartyMode); + addMessage({ + type: "round_header", + content: "", + round: p.round, + mode: p.mode as PartyMode, + }); + }, [addMessage]); + + const handlePersonaThinking = useCallback((payload: unknown) => { + const p = payload as { personaKey: string }; + setThinkingPersonas((prev) => new Set(prev).add(p.personaKey)); + }, []); + + const handlePersonaSpoke = useCallback((payload: unknown) => { + const p = payload as { personaKey: string; emoji: string; name: string; message: string; round: number }; + setThinkingPersonas((prev) => { + const next = new Set(prev); + next.delete(p.personaKey); + return next; + }); + addMessage({ + type: "spoke", + personaKey: p.personaKey, + personaEmoji: p.emoji, + personaName: p.name, + content: p.message, + round: p.round, + }); + }, [addMessage]); + + const handleRoundComplete = useCallback((payload: unknown) => { + const p = payload as { round: number }; + setThinkingPersonas(new Set()); + void p; + }, []); + + const handleContextAdded = useCallback((payload: unknown) => { + const p = payload as { type: string; name?: string }; + addMessage({ + type: "context", + content: `Context added: ${p.type}${p.name ? ` (${p.name})` : ""}`, + }); + }, [addMessage]); + + const handleSummaryReady = useCallback((payload: unknown) => { + const p = payload as PartySummary; + setSummary(p); + addMessage({ + type: "summary", + content: p.markdown ?? "", + }); + }, [addMessage]); + + const handleArtifact = useCallback((payload: unknown) => { + const p = payload as { name: string; content: string }; + addMessage({ + type: "artifact", + content: `**${p.name}**\n\n${p.content}`, + }); + }, [addMessage]); + + const handlePartyClosed = useCallback((_payload: unknown) => { + setStatus("closed"); + setThinkingPersonas(new Set()); + }, []); + + // --- Subscribe to events --- + useWsEvent(Events.PARTY_STARTED, handlePartyStarted); + useWsEvent(Events.PARTY_PERSONA_INTRO, handlePersonaIntro); + useWsEvent(Events.PARTY_ROUND_STARTED, handleRoundStarted); + useWsEvent(Events.PARTY_PERSONA_THINKING, handlePersonaThinking); + useWsEvent(Events.PARTY_PERSONA_SPOKE, handlePersonaSpoke); + useWsEvent(Events.PARTY_ROUND_COMPLETE, handleRoundComplete); + useWsEvent(Events.PARTY_CONTEXT_ADDED, handleContextAdded); + useWsEvent(Events.PARTY_SUMMARY_READY, handleSummaryReady); + useWsEvent(Events.PARTY_ARTIFACT, handleArtifact); + useWsEvent(Events.PARTY_CLOSED, handlePartyClosed); + + // --- RPC calls --- + + const listSessions = useCallback(async () => { + if (!connected) return; + setLoading(true); + try { + const res = await ws.call<{ sessions: PartySession[] }>(Methods.PARTY_LIST); + setSessions(res.sessions ?? []); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [ws, connected]); + + const startParty = useCallback( + async (topic: string, teamPreset?: string, personaKeys?: string[]) => { + if (!connected) return; + setLoading(true); + try { + await ws.call(Methods.PARTY_START, { + topic, + teamPreset: teamPreset ?? undefined, + personaKeys: personaKeys ?? undefined, + }); + } catch { + // ignore + } finally { + setLoading(false); + } + }, + [ws, connected], + ); + + const runRound = useCallback( + async (sessionId: string, roundMode?: PartyMode) => { + await ws.call(Methods.PARTY_ROUND, { + sessionId, + mode: roundMode ?? undefined, + }); + }, + [ws], + ); + + const askQuestion = useCallback( + async (sessionId: string, text: string) => { + await ws.call(Methods.PARTY_QUESTION, { sessionId, text }); + }, + [ws], + ); + + const addContext = useCallback( + async (sessionId: string, type: string, name?: string, content?: string) => { + await ws.call(Methods.PARTY_ADD_CONTEXT, { sessionId, type, name, content }); + }, + [ws], + ); + + const getSummary = useCallback( + async (sessionId: string) => { + await ws.call(Methods.PARTY_SUMMARY, { sessionId }); + }, + [ws], + ); + + const exitParty = useCallback( + async (sessionId: string) => { + await ws.call(Methods.PARTY_EXIT, { sessionId }); + }, + [ws], + ); + + return { + // state + sessions, + activeSessionId, + messages, + personas, + thinkingPersonas, + round, + mode, + status, + summary, + loading, + + // actions + listSessions, + startParty, + runRound, + askQuestion, + addContext, + getSummary, + exitParty, + setActiveSessionId, + getPersonaColor, + }; +} diff --git a/ui/web/src/pages/party/party-controls.tsx b/ui/web/src/pages/party/party-controls.tsx new file mode 100644 index 00000000..4d06f178 --- /dev/null +++ b/ui/web/src/pages/party/party-controls.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Play, MessageCircleQuestion, FileText, LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ConfirmDialog } from "@/components/shared/confirm-dialog"; +import { cn } from "@/lib/utils"; +import type { PartyMode } from "./hooks/use-party"; + +interface PartyControlsProps { + sessionId: string; + currentMode: PartyMode; + status: "idle" | "active" | "closed"; + onRunRound: (sessionId: string, mode?: PartyMode) => Promise; + onAskQuestion: (sessionId: string, text: string) => Promise; + onGetSummary: (sessionId: string) => Promise; + onExit: (sessionId: string) => Promise; +} + +const MODE_OPTIONS: { value: PartyMode; labelKey: string }[] = [ + { value: "standard", labelKey: "mode.standard" }, + { value: "deep", labelKey: "mode.deep" }, + { value: "token_ring", labelKey: "mode.token_ring" }, +]; + +export function PartyControls({ + sessionId, + currentMode, + status, + onRunRound, + onAskQuestion, + onGetSummary, + onExit, +}: PartyControlsProps) { + const { t } = useTranslation("party"); + const [selectedMode, setSelectedMode] = useState(currentMode); + const [questionOpen, setQuestionOpen] = useState(false); + const [questionText, setQuestionText] = useState(""); + const [exitOpen, setExitOpen] = useState(false); + const [runningAction, setRunningAction] = useState(null); + + const isClosed = status === "closed"; + + const handleRunRound = async () => { + setRunningAction("round"); + try { + await onRunRound(sessionId, selectedMode); + } finally { + setRunningAction(null); + } + }; + + const handleQuestion = async () => { + if (!questionText.trim()) return; + setRunningAction("question"); + try { + await onAskQuestion(sessionId, questionText.trim()); + setQuestionText(""); + setQuestionOpen(false); + } finally { + setRunningAction(null); + } + }; + + const handleSummary = async () => { + setRunningAction("summary"); + try { + await onGetSummary(sessionId); + } finally { + setRunningAction(null); + } + }; + + const handleExit = async () => { + setRunningAction("exit"); + try { + await onExit(sessionId); + setExitOpen(false); + } finally { + setRunningAction(null); + } + }; + + return ( +
+
+ {/* Mode toggle */} +
+ {MODE_OPTIONS.map((opt) => ( + + ))} +
+ +
+ + {/* Continue [C] */} + + + {/* Question [Q] */} + {questionOpen ? ( +
+ setQuestionText(e.target.value)} + placeholder="Ask a question..." + className="h-8 w-48 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") handleQuestion(); + if (e.key === "Escape") setQuestionOpen(false); + }} + autoFocus + /> + +
+ ) : ( + + )} + + {/* Summary [D] */} + + + {/* Spacer */} +
+ + {/* Exit [E] */} + +
+ + +
+ ); +} diff --git a/ui/web/src/pages/party/party-page.tsx b/ui/web/src/pages/party/party-page.tsx new file mode 100644 index 00000000..40b4ac87 --- /dev/null +++ b/ui/web/src/pages/party/party-page.tsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { PartyPopper, Plus } from "lucide-react"; +import { PageHeader } from "@/components/shared/page-header"; +import { EmptyState } from "@/components/shared/empty-state"; +import { CardSkeleton } from "@/components/shared/loading-skeleton"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useDeferredLoading } from "@/hooks/use-deferred-loading"; +import { cn } from "@/lib/utils"; +import { useParty } from "./hooks/use-party"; +import { PartyStartDialog } from "./party-start-dialog"; +import { PartySession } from "./party-session"; +import { PersonaSidebar } from "./persona-sidebar"; +import { PartyControls } from "./party-controls"; + +export function PartyPage() { + const { t } = useTranslation("party"); + const { + sessions, + activeSessionId, + messages, + personas, + thinkingPersonas, + round, + mode, + status, + loading, + listSessions, + startParty, + runRound, + askQuestion, + getSummary, + exitParty, + setActiveSessionId, + getPersonaColor, + } = useParty(); + + const [createOpen, setCreateOpen] = useState(false); + const showSkeleton = useDeferredLoading(loading && sessions.length === 0); + + useEffect(() => { + listSessions(); + }, [listSessions]); + + const hasActiveSession = activeSessionId !== null && status !== "idle"; + + return ( +
+ {/* Header area */} +
+ setCreateOpen(true)} className="gap-1"> + {t("newParty")} + + } + /> +
+ + {/* Main content */} +
+ {/* Session list panel */} +
+
+

+ Sessions +

+
+ +
+ {showSkeleton ? ( + Array.from({ length: 3 }).map((_, i) => ( + + )) + ) : sessions.length === 0 ? ( +

+ {t("noSessions")} +

+ ) : ( + sessions.map((session) => ( + + )) + )} +
+
+
+ + {/* Active session area */} + {hasActiveSession ? ( +
+ {/* Chat + persona sidebar */} +
+ {/* Chat messages */} + + + {/* Right sidebar with persona list */} + +
+ + {/* Bottom controls */} + +
+ ) : ( +
+ setCreateOpen(true)} className="gap-1"> + {t("newParty")} + + } + /> +
+ )} +
+ + {/* Start dialog */} + +
+ ); +} diff --git a/ui/web/src/pages/party/party-session.tsx b/ui/web/src/pages/party/party-session.tsx new file mode 100644 index 00000000..1d1f9f50 --- /dev/null +++ b/ui/web/src/pages/party/party-session.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import { cn } from "@/lib/utils"; +import type { PartyMessage, PartyMode } from "./hooks/use-party"; + +interface PartySessionProps { + messages: PartyMessage[]; + getPersonaColor: (key: string) => string; +} + +function RoundHeader({ round, mode, t }: { round: number; mode?: PartyMode; t: (k: string, opts?: Record) => string }) { + const modeLabel = mode ? t(`mode.${mode}`) : ""; + return ( +
+
+ + {t("round", { n: round })} {modeLabel && `[${modeLabel}]`} + +
+
+ ); +} + +function PersonaMessage({ + message, + borderColor, +}: { + message: PartyMessage; + borderColor: string; +}) { + const isIntro = message.type === "intro"; + + return ( +
+
+ {message.personaEmoji} + {message.personaName} + {isIntro && ( + intro + )} +
+
+ +
+
+ ); +} + +function ContextMessage({ message }: { message: PartyMessage }) { + return ( +
+ + {message.content} + +
+ ); +} + +function SummaryMessage({ message }: { message: PartyMessage }) { + return ( +
+ +
+ ); +} + +function ArtifactMessage({ message }: { message: PartyMessage }) { + return ( +
+ +
+ ); +} + +export function PartySession({ messages, getPersonaColor }: PartySessionProps) { + const { t } = useTranslation("party"); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length]); + + return ( + +
+ {messages.map((msg) => { + switch (msg.type) { + case "round_header": + return ( + + ); + case "intro": + case "spoke": + return ( + + ); + case "context": + return ; + case "summary": + return ; + case "artifact": + return ; + default: + return null; + } + })} +
+
+ + ); +} diff --git a/ui/web/src/pages/party/party-start-dialog.tsx b/ui/web/src/pages/party/party-start-dialog.tsx new file mode 100644 index 00000000..4e8932a8 --- /dev/null +++ b/ui/web/src/pages/party/party-start-dialog.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +const TEAM_PRESETS = [ + "payment_feature", + "security_review", + "sprint_planning", + "architecture_decision", + "ux_review", + "incident_response", +] as const; + +const ALL_PERSONAS = [ + { key: "product_manager", emoji: "\ud83d\udcca", label: "Product Manager" }, + { key: "tech_lead", emoji: "\ud83d\udd27", label: "Tech Lead" }, + { key: "security_analyst", emoji: "\ud83d\udd12", label: "Security Analyst" }, + { key: "qa_engineer", emoji: "\ud83e\uddea", label: "QA Engineer" }, + { key: "devops_engineer", emoji: "\u2699\ufe0f", label: "DevOps Engineer" }, + { key: "ux_designer", emoji: "\ud83c\udfa8", label: "UX Designer" }, + { key: "backend_dev", emoji: "\ud83d\udda5\ufe0f", label: "Backend Dev" }, + { key: "frontend_dev", emoji: "\ud83c\udf10", label: "Frontend Dev" }, + { key: "data_analyst", emoji: "\ud83d\udcc8", label: "Data Analyst" }, + { key: "compliance_officer", emoji: "\ud83d\udccb", label: "Compliance Officer" }, + { key: "scrum_master", emoji: "\ud83c\udfc3", label: "Scrum Master" }, + { key: "architect", emoji: "\ud83c\udfd7\ufe0f", label: "Architect" }, + { key: "dba", emoji: "\ud83d\uddc4\ufe0f", label: "DBA" }, + { key: "business_analyst", emoji: "\ud83d\udcbc", label: "Business Analyst" }, +] as const; + +interface PartyStartDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onStart: (topic: string, teamPreset?: string, personaKeys?: string[]) => Promise; +} + +export function PartyStartDialog({ open, onOpenChange, onStart }: PartyStartDialogProps) { + const { t } = useTranslation("party"); + const [topic, setTopic] = useState(""); + const [selectedPreset, setSelectedPreset] = useState(null); + const [isCustom, setIsCustom] = useState(false); + const [selectedPersonas, setSelectedPersonas] = useState>(new Set()); + const [loading, setLoading] = useState(false); + + const togglePersona = (key: string) => { + setSelectedPersonas((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const handleSelectPreset = (preset: string) => { + setSelectedPreset(preset); + setIsCustom(false); + setSelectedPersonas(new Set()); + }; + + const handleSelectCustom = () => { + setSelectedPreset(null); + setIsCustom(true); + }; + + const canStart = topic.trim().length > 0 && (selectedPreset || (isCustom && selectedPersonas.size >= 2)); + + const handleStart = async () => { + if (!canStart) return; + setLoading(true); + try { + await onStart( + topic.trim(), + selectedPreset ?? undefined, + isCustom ? Array.from(selectedPersonas) : undefined, + ); + onOpenChange(false); + setTopic(""); + setSelectedPreset(null); + setIsCustom(false); + setSelectedPersonas(new Set()); + } catch { + // error handled upstream + } finally { + setLoading(false); + } + }; + + return ( + + + + {t("newParty")} + + +
+ {/* Topic input */} +
+ + setTopic(e.target.value)} + placeholder="e.g., Design payment reconciliation service..." + /> +
+ + {/* Team presets */} +
+ +
+ {TEAM_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Custom team option */} +
+ + + {isCustom && ( +
+ {ALL_PERSONAS.map((persona) => ( + + ))} +
+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/ui/web/src/pages/party/persona-sidebar.tsx b/ui/web/src/pages/party/persona-sidebar.tsx new file mode 100644 index 00000000..04334c8c --- /dev/null +++ b/ui/web/src/pages/party/persona-sidebar.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import type { PersonaInfo } from "./hooks/use-party"; + +interface PersonaSidebarProps { + personas: PersonaInfo[]; + thinkingPersonas: Set; +} + +export function PersonaSidebar({ personas, thinkingPersonas }: PersonaSidebarProps) { + const { t } = useTranslation("party"); + + if (personas.length === 0) return null; + + return ( +
+
+

+ Personas +

+
+
+ {personas.map((persona) => { + const isThinking = thinkingPersonas.has(persona.key); + return ( +
+ {/* Status indicator */} + + {/* Persona info */} +
+
+ {persona.emoji} + + {persona.name} + +
+

+ {persona.role} +

+
+ {/* Status label */} + {isThinking && ( + + {t("status.thinking")} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index ab01b614..bff943cd 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -84,6 +84,9 @@ const PendingMessagesPage = lazy(() => const MemoryPage = lazy(() => import("@/pages/memory/memory-page").then((m) => ({ default: m.MemoryPage })), ); +const PartyPage = lazy(() => + import("@/pages/party/party-page").then((m) => ({ default: m.PartyPage })), +); function PageLoader() { return ( @@ -152,6 +155,7 @@ export function AppRoutes() { } /> } /> } /> + } /> {/* Catch-all → overview */}