From 636f909a66da311e1142c53a74f4bc52433fa8d4 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Tue, 26 May 2026 17:27:18 +0800 Subject: [PATCH 1/2] Feat: Support Threads of PicoClaw --- .codegraph/.gitignore | 16 ++ docs/api.md | 19 +- docs/api.zh.md | 19 +- docs/im-threads.md | 12 +- docs/im-threads.zh.md | 12 +- internal/agent/manager_config_test.go | 35 +++ internal/api/bot_compat.go | 16 +- internal/api/handler_test.go | 207 +++++++++++++++ internal/channel/codexbridge/bridge_test.go | 14 +- internal/channel/codexbridge/sse_client.go | 15 +- internal/im/bot_bridge.go | 243 +++++++++++++++++- internal/im/bot_bridge_test.go | 71 ++++- .../defaults/picoclaw-config.json | 6 +- .../workspace/useConversationController.ts | 13 +- web/app/src/models/composer.ts | 18 ++ web/app/src/models/conversations.ts | 1 - .../ConversationPane/ConversationPane.css | 19 ++ .../ConversationPane/ConversationPane.tsx | 178 +++++++++++-- .../ConversationPane/ConversationThreads.css | 9 + .../components/ConversationPane.test.tsx | 210 +++++++++++++++ web/app/tests/legacy-contract.test.ts | 10 +- web/app/tests/models/composer.test.ts | 15 ++ web/app/tests/models/conversations.test.ts | 21 ++ 23 files changed, 1115 insertions(+), 64 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 web/app/tests/components/ConversationPane.test.tsx diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..9de0f169 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/docs/api.md b/docs/api.md index bd10c434..6ba46c13 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1035,13 +1035,14 @@ Example single event: ```text id: msg-1 event: message -data: {"message_id":"msg-1","room_id":"room-1","sender_id":"u-admin","text":"hello","thread_root_id":"msg-root","thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"u-admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} +data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"room-1","sender_id":"u-admin","text":"hello","thread_root_id":"msg-root","context":{"channel":"csgclaw","chat_id":"room-1","chat_type":"direct","topic_id":"msg-root","sender_id":"u-admin","message_id":"msg-1"},"thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"u-admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} ``` For thread replies, `thread_root_id` is the root message ID and `thread_context` carries the deterministic hidden context captured when the thread was started. Bot/LLM bridges use it as prompt context; it is not a list -of thread replies. +of thread replies. PicoClaw-native clients can use `context.topic_id` as the +same thread/session identifier. ### `POST /api/bots/{id}/messages/send` @@ -1061,6 +1062,20 @@ Example request body: inside that IM thread. The detailed response behavior depends on the compatibility bridge implementation. +PicoClaw outbound message shape is also accepted: + +```json +{ + "chat_id": "room-1", + "content": "hello", + "context": { + "channel": "csgclaw", + "chat_id": "room-1", + "topic_id": "msg-root" + } +} +``` + ### `GET /api/bots/{id}/llm/models` ### `GET /api/bots/{id}/llm/v1/models` diff --git a/docs/api.zh.md b/docs/api.zh.md index 3bb5e5c5..42166f2d 100644 --- a/docs/api.zh.md +++ b/docs/api.zh.md @@ -1029,12 +1029,13 @@ Bot 和 Codex bridge 使用的 thread/session 隔离规则见 ```text id: msg-1 event: message -data: {"message_id":"msg-1","room_id":"room-1","sender_id":"u-admin","text":"hello","thread_root_id":"msg-root","thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"u-admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} +data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"room-1","sender_id":"u-admin","text":"hello","thread_root_id":"msg-root","context":{"channel":"csgclaw","chat_id":"room-1","chat_type":"direct","topic_id":"msg-root","sender_id":"u-admin","message_id":"msg-1"},"thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"u-admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} ``` 对于 thread replies,`thread_root_id` 是 root message ID,`thread_context` 携带 thread 开启时记录的确定性隐藏上下文。Bot/LLM bridge 会把它作为 -prompt context 使用;它不是 thread reply 列表。 +prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 可以把 +`context.topic_id` 当作同一个 thread/session 标识。 ### `POST /api/bots/{id}/messages/send` @@ -1052,6 +1053,20 @@ prompt context 使用;它不是 thread reply 列表。 `thread_root_id` 可选;传入时 bot 响应会发送到该 IM thread 中。具体响应由兼容桥实现决定。 +也接受 PicoClaw outbound message 形态: + +```json +{ + "chat_id": "room-1", + "content": "hello", + "context": { + "channel": "csgclaw", + "chat_id": "room-1", + "topic_id": "msg-root" + } +} +``` + ### `GET /api/bots/{id}/llm/models` ### `GET /api/bots/{id}/llm/v1/models` diff --git a/docs/im-threads.md b/docs/im-threads.md index 3413d491..6adbf69b 100644 --- a/docs/im-threads.md +++ b/docs/im-threads.md @@ -150,15 +150,21 @@ Thread-aware bot events may include: - `thread_root_id`: root message ID when the event is inside a thread. - `thread_context`: hidden context snapshot and summary for the thread root. +- `context.topic_id`: PicoClaw-native topic/session ID. For CSGClaw IM + threads this is the same value as `thread_root_id`. `thread_context` is prompt context, not visible thread history. -Bot sends may include `thread_root_id`. When present, the message is sent as a -reply in that thread. +Bot sends may include either CSGClaw fields (`room_id`, `text`, +`thread_root_id`) or PicoClaw outbound fields (`chat_id`, `content`, +`context.topic_id`). When a thread root/topic is present, the message is sent as +a reply in that thread. This maps to PicoClaw/topic isolation requirements: a runtime should treat `room_id` as the normal conversation key and `room_id:thread_root_id` as the -thread conversation key. +thread conversation key. Generated PicoClaw configs set session dimensions to +`["chat", "topic"]` so `context.topic_id` creates a separate PicoClaw session +for every CSGClaw IM thread. ## Codex Bridge Session Isolation diff --git a/docs/im-threads.zh.md b/docs/im-threads.zh.md index 2d2b595b..1157ca31 100644 --- a/docs/im-threads.zh.md +++ b/docs/im-threads.zh.md @@ -140,14 +140,20 @@ Thread-aware bot event 可能包含: - `thread_root_id`:事件位于 thread 内时的 root message ID。 - `thread_context`:该 thread root 的隐藏上下文快照和 summary。 +- `context.topic_id`:PicoClaw 原生 topic/session ID。对 CSGClaw IM + threads 来说,它与 `thread_root_id` 相同。 `thread_context` 是 prompt context,不是可见 thread 历史。 -Bot send 可以传入 `thread_root_id`。传入后,消息会作为该 thread 内的 reply -发送。 +Bot send 可以传入 CSGClaw 字段(`room_id`、`text`、`thread_root_id`), +也可以传入 PicoClaw outbound 字段(`chat_id`、`content`、 +`context.topic_id`)。存在 thread root/topic 时,消息会作为该 thread 内的 +reply 发送。 这对应 PicoClaw/topic 隔离需求:runtime 应把 `room_id` 视为普通会话 key, -把 `room_id:thread_root_id` 视为 thread 会话 key。 +把 `room_id:thread_root_id` 视为 thread 会话 key。生成的 PicoClaw config +会把 session dimensions 设为 `["chat", "topic"]`,因此 +`context.topic_id` 会为每个 CSGClaw IM thread 建立独立的 PicoClaw session。 ## Codex Bridge 会话隔离 diff --git a/internal/agent/manager_config_test.go b/internal/agent/manager_config_test.go index 50b126c0..bcee3414 100644 --- a/internal/agent/manager_config_test.go +++ b/internal/agent/manager_config_test.go @@ -86,6 +86,41 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { if got, want := execTool["timeout_seconds"], float64(600); got != want { t.Fatalf("tools.exec.timeout_seconds = %v, want %v", got, want) } + session, ok := rendered["session"].(map[string]any) + if !ok { + t.Fatalf("renderAgentPicoClawConfig() missing session in:\n%s", text) + } + dimensions, ok := session["dimensions"].([]any) + if !ok { + t.Fatalf("session.dimensions = %T, want array in:\n%s", session["dimensions"], text) + } + if got, want := stringifyJSONList(dimensions), []string{"chat", "topic"}; !stringSlicesEqual(got, want) { + t.Fatalf("session.dimensions = %v, want %v", got, want) + } +} + +func stringifyJSONList(values []any) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if !ok { + continue + } + result = append(result, text) + } + return result +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true } func TestPicoclawBridgeModelIDPrefixesOpenAIForSlashModelNames(t *testing.T) { diff --git a/internal/api/bot_compat.go b/internal/api/bot_compat.go index f25ba9b7..5d4fff39 100644 --- a/internal/api/bot_compat.go +++ b/internal/api/bot_compat.go @@ -357,20 +357,26 @@ func (h *Handler) handleBotSendMessage(w http.ResponseWriter, r *http.Request, b http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) return } + roomID := req.ResolvedRoomID() + text := req.ResolvedText() + threadRootID := req.ResolvedThreadRootID() + if threadRootID == "" && h.botBridge != nil { + threadRootID = h.botBridge.ThreadRootForReply(botID, roomID) + } message, err := h.im.DeliverMessage(im.DeliverMessageRequest{ - RoomID: req.RoomID, + RoomID: roomID, SenderID: botID, - Content: req.Text, + Content: text, MessageID: req.MessageID, - ThreadRootID: req.ThreadRootID, + ThreadRootID: threadRootID, }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - h.publishMessageCreated(req.RoomID, botID, message) - h.publishThreadUpdated(req.RoomID, message) + h.publishMessageCreated(roomID, botID, message) + h.publishThreadUpdated(roomID, message) writeJSON(w, http.StatusOK, map[string]string{"message_id": message.ID}) } diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 65d815df..abd3f9ee 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -3334,6 +3334,213 @@ func TestHandleBotSendMessageRequiresIMService(t *testing.T) { } } +func TestHandleBotSendMessageDefaultsToRecentThreadScope(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-manager", Name: "manager", Handle: "manager"}, + }, + Rooms: []im.Room{ + { + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", "u-manager"}, + Messages: []im.Message{ + {ID: "msg-root", SenderID: "u-manager", Content: "How can I help?", CreatedAt: now}, + }, + }, + }, + }) + if _, _, err := imSvc.StartThread(im.StartThreadRequest{RoomID: "room-1", RootMessageID: "msg-root"}); err != nil { + t.Fatalf("StartThread() error = %v", err) + } + inbound, err := imSvc.CreateMessage(im.CreateMessageRequest{ + RoomID: "room-1", + SenderID: "u-admin", + Content: "thread question", + RelatesTo: &im.MessageRelation{ + RelType: im.RelationTypeThread, + EventID: "msg-root", + }, + }) + if err != nil { + t.Fatalf("CreateMessage(thread question) error = %v", err) + } + room, ok := imSvc.Room("room-1") + if !ok { + t.Fatal("Room(room-1) = false, want room") + } + sender, ok := imSvc.User("u-admin") + if !ok { + t.Fatal("User(u-admin) = false, want user") + } + bridge := im.NewBotBridge("") + events, cancel := bridge.Subscribe("u-manager") + defer cancel() + bridge.PublishMessageEvent(room, sender, inbound) + select { + case evt := <-events: + if evt.ThreadRootID != "msg-root" { + t.Fatalf("bot event ThreadRootID = %q, want msg-root", evt.ThreadRootID) + } + bridge.Ack("u-manager", evt.MessageID) + case <-time.After(time.Second): + t.Fatal("PublishMessageEvent() timed out waiting for threaded event") + } + + srv := &Handler{im: imSvc, botBridge: bridge, serverNoAuth: true} + req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"thread answer"}`)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var sent struct { + MessageID string `json:"message_id"` + } + if err := json.NewDecoder(rec.Body).Decode(&sent); err != nil { + t.Fatalf("decode send response: %v", err) + } + messages, err := imSvc.ListMessagesWithOptions("room-1", im.ListMessagesOptions{IncludeThreadReplies: true}) + if err != nil { + t.Fatalf("ListMessagesWithOptions() error = %v", err) + } + var reply im.Message + for _, message := range messages { + if message.ID == sent.MessageID { + reply = message + break + } + } + if reply.ID == "" { + t.Fatalf("sent message %q not found in room messages", sent.MessageID) + } + if reply.RelatesTo == nil || reply.RelatesTo.RelType != im.RelationTypeThread || reply.RelatesTo.EventID != "msg-root" { + t.Fatalf("reply.RelatesTo = %+v, want m.thread -> msg-root", reply.RelatesTo) + } + + topLevel, err := imSvc.CreateMessage(im.CreateMessageRequest{ + RoomID: "room-1", + SenderID: "u-admin", + Content: "top-level follow-up", + }) + if err != nil { + t.Fatalf("CreateMessage(top-level follow-up) error = %v", err) + } + room, ok = imSvc.Room("room-1") + if !ok { + t.Fatal("Room(room-1) = false before top-level publish, want room") + } + bridge.PublishMessageEvent(room, sender, topLevel) + select { + case evt := <-events: + if evt.ThreadRootID != "" { + t.Fatalf("top-level bot event ThreadRootID = %q, want empty", evt.ThreadRootID) + } + bridge.Ack("u-manager", evt.MessageID) + case <-time.After(time.Second): + t.Fatal("PublishMessageEvent() timed out waiting for top-level event") + } + + req = httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"top-level answer"}`)) + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("top-level response status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if err := json.NewDecoder(rec.Body).Decode(&sent); err != nil { + t.Fatalf("decode top-level send response: %v", err) + } + messages, err = imSvc.ListMessagesWithOptions("room-1", im.ListMessagesOptions{IncludeThreadReplies: true}) + if err != nil { + t.Fatalf("ListMessagesWithOptions() after top-level response error = %v", err) + } + reply = im.Message{} + for _, message := range messages { + if message.ID == sent.MessageID { + reply = message + break + } + } + if reply.ID == "" { + t.Fatalf("top-level sent message %q not found in room messages", sent.MessageID) + } + if reply.RelatesTo != nil { + t.Fatalf("top-level reply.RelatesTo = %+v, want nil after top-level event reset", reply.RelatesTo) + } +} + +func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-manager", Name: "manager", Handle: "manager"}, + }, + Rooms: []im.Room{ + { + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", "u-manager"}, + Messages: []im.Message{ + {ID: "msg-root", SenderID: "u-manager", Content: "How can I help?", CreatedAt: now}, + }, + }, + }, + }) + if _, _, err := imSvc.StartThread(im.StartThreadRequest{RoomID: "room-1", RootMessageID: "msg-root"}); err != nil { + t.Fatalf("StartThread() error = %v", err) + } + + srv := &Handler{im: imSvc, botBridge: im.NewBotBridge(""), serverNoAuth: true} + req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{ + "chat_id": "room-1", + "content": "direct PicoClaw thread answer", + "context": { + "channel": "csgclaw", + "chat_id": "room-1", + "chat_type": "direct", + "topic_id": "msg-root" + } + }`)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var sent struct { + MessageID string `json:"message_id"` + } + if err := json.NewDecoder(rec.Body).Decode(&sent); err != nil { + t.Fatalf("decode send response: %v", err) + } + messages, err := imSvc.ListMessagesWithOptions("room-1", im.ListMessagesOptions{IncludeThreadReplies: true}) + if err != nil { + t.Fatalf("ListMessagesWithOptions() error = %v", err) + } + var reply im.Message + for _, message := range messages { + if message.ID == sent.MessageID { + reply = message + break + } + } + if reply.ID == "" { + t.Fatalf("sent message %q not found in room messages", sent.MessageID) + } + if reply.Content != "direct PicoClaw thread answer" { + t.Fatalf("reply.Content = %q, want direct PicoClaw thread answer", reply.Content) + } + if reply.RelatesTo == nil || reply.RelatesTo.RelType != im.RelationTypeThread || reply.RelatesTo.EventID != "msg-root" { + t.Fatalf("reply.RelatesTo = %+v, want m.thread -> msg-root", reply.RelatesTo) + } +} + func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { now := time.Now().UTC() imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ diff --git a/internal/channel/codexbridge/bridge_test.go b/internal/channel/codexbridge/bridge_test.go index a714a68a..f67b187c 100644 --- a/internal/channel/codexbridge/bridge_test.go +++ b/internal/channel/codexbridge/bridge_test.go @@ -1061,7 +1061,9 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { "event: message\n" + "data: {\"message_id\":\"m-2\",\"room_id\":\"room-1\",\"chat_type\":\"group\",\"text\":\" hello\"}\n\n" + "event: message\n" + - "data: {\"message_id\":\"m-3\",\"room_id\":\"room-2\",\"chat_type\":\"direct\",\"text\":\"direct hello\"}\n\n", + "data: {\"message_id\":\"m-3\",\"room_id\":\"room-1\",\"chat_type\":\"group\",\"text\":\"@codex hello\",\"mentions\":[\"u-codex\"],\"thread_root_id\":\"msg-root\"}\n\n" + + "event: message\n" + + "data: {\"message_id\":\"m-4\",\"room_id\":\"room-2\",\"chat_type\":\"direct\",\"text\":\"direct hello\"}\n\n", )), }, nil }), @@ -1079,8 +1081,8 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { } } - if len(got) != 2 { - t.Fatalf("received %d events, want 2: %+v", len(got), got) + if len(got) != 3 { + t.Fatalf("received %d events, want 3: %+v", len(got), got) } if got[0].MessageID != "m-2" { t.Fatalf("received first event = %+v, want m-2", got[0]) @@ -1088,6 +1090,12 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { if got[1].MessageID != "m-3" { t.Fatalf("received second event = %+v, want m-3", got[1]) } + if got[1].ThreadRootID != "msg-root" { + t.Fatalf("received second event ThreadRootID = %q, want msg-root", got[1].ThreadRootID) + } + if got[2].MessageID != "m-4" { + t.Fatalf("received third event = %+v, want m-4", got[2]) + } } type roundTripFunc func(*http.Request) (*http.Response, error) diff --git a/internal/channel/codexbridge/sse_client.go b/internal/channel/codexbridge/sse_client.go index 2a3a1de1..b9505c83 100644 --- a/internal/channel/codexbridge/sse_client.go +++ b/internal/channel/codexbridge/sse_client.go @@ -103,7 +103,7 @@ func (c *HTTPClient) StreamEvents(ctx context.Context, botID, lastEventID string if strings.TrimSpace(event.ChatType) != "group" { return true } - return hasInboundBotAtMention(event.Text, botID) + return botEventMentions(event, botID) || hasInboundBotAtMention(event.Text, botID) }); err != nil && ctx.Err() == nil { errs <- err } @@ -210,6 +210,19 @@ func emitSSEEvent(eventName string, dataLines []string, events chan<- BotEvent, return nil } +func botEventMentions(event BotEvent, botID string) bool { + botID = strings.TrimSpace(botID) + if botID == "" { + return false + } + for _, mention := range event.Mentions { + if strings.TrimSpace(mention) == botID { + return true + } + } + return false +} + func hasInboundBotAtMention(content, botID string) bool { content = strings.TrimSpace(content) botID = strings.TrimSpace(botID) diff --git a/internal/im/bot_bridge.go b/internal/im/bot_bridge.go index bcfc9c5c..d1f9fcae 100644 --- a/internal/im/bot_bridge.go +++ b/internal/im/bot_bridge.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "sync" + "time" ) type BotBridge struct { @@ -13,20 +14,31 @@ type BotBridge struct { pending map[string][]BotEvent inflight map[string]map[string]BotEvent seen map[string]map[string]struct{} + threadScope map[string]map[string]botThreadScope } const maxPendingBotEventsPerBot = 64 +const botThreadScopeTTL = 10 * time.Minute + +type botThreadScope struct { + RootID string + UpdatedAt time.Time +} type BotEvent struct { MessageID string `json:"message_id"` RoomID string `json:"room_id"` + Channel string `json:"channel,omitempty"` + ChatID string `json:"chat_id,omitempty"` ChatType string `json:"chat_type"` Sender BotSender `json:"sender"` + SenderID string `json:"sender_id,omitempty"` Text string `json:"text"` Timestamp string `json:"timestamp"` Mentions []string `json:"mentions,omitempty"` ThreadRootID string `json:"thread_root_id,omitempty"` ThreadContext *BotThreadContext `json:"thread_context,omitempty"` + Context BotMessageContext `json:"context,omitempty"` } type BotSender struct { @@ -35,6 +47,23 @@ type BotSender struct { DisplayName string `json:"display_name,omitempty"` } +type BotMessageContext struct { + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + ChatID string `json:"chat_id,omitempty"` + ChatType string `json:"chat_type,omitempty"` + TopicID string `json:"topic_id,omitempty"` + SpaceID string `json:"space_id,omitempty"` + SpaceType string `json:"space_type,omitempty"` + SenderID string `json:"sender_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + Mentioned bool `json:"mentioned,omitempty"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ReplyToSenderID string `json:"reply_to_sender_id,omitempty"` + ReplyHandles map[string]string `json:"reply_handles,omitempty"` + Raw map[string]string `json:"raw,omitempty"` +} + type BotThreadContext struct { RootMessageID string `json:"root_message_id"` Context []Message `json:"context,omitempty"` @@ -42,10 +71,50 @@ type BotThreadContext struct { } type BotSendMessageRequest struct { - RoomID string `json:"room_id"` - Text string `json:"text"` - MessageID string `json:"message_id,omitempty"` - ThreadRootID string `json:"thread_root_id,omitempty"` + RoomID string `json:"room_id"` + ChatID string `json:"chat_id,omitempty"` + Text string `json:"text"` + Content string `json:"content,omitempty"` + MessageID string `json:"message_id,omitempty"` + ThreadRootID string `json:"thread_root_id,omitempty"` + TopicID string `json:"topic_id,omitempty"` + Context *BotMessageContext `json:"context,omitempty"` +} + +func (r BotSendMessageRequest) ResolvedRoomID() string { + if roomID := strings.TrimSpace(r.RoomID); roomID != "" { + return roomID + } + if chatID := strings.TrimSpace(r.ChatID); chatID != "" { + return chatID + } + if r.Context != nil { + return strings.TrimSpace(r.Context.ChatID) + } + return "" +} + +func (r BotSendMessageRequest) ResolvedText() string { + if text := strings.TrimSpace(r.Text); text != "" { + return r.Text + } + if content := strings.TrimSpace(r.Content); content != "" { + return r.Content + } + return "" +} + +func (r BotSendMessageRequest) ResolvedThreadRootID() string { + if rootID := strings.TrimSpace(r.ThreadRootID); rootID != "" { + return rootID + } + if topicID := strings.TrimSpace(r.TopicID); topicID != "" { + return topicID + } + if r.Context != nil { + return strings.TrimSpace(r.Context.TopicID) + } + return "" } func NewBotBridge(string) *BotBridge { @@ -54,6 +123,7 @@ func NewBotBridge(string) *BotBridge { pending: make(map[string][]BotEvent), inflight: make(map[string]map[string]BotEvent), seen: make(map[string]map[string]struct{}), + threadScope: make(map[string]map[string]botThreadScope), } } @@ -167,6 +237,28 @@ func (b *BotBridge) Ack(botID, messageID string) { b.markSeenLocked(botID, messageID) } +func (b *BotBridge) ThreadRootForReply(botID, roomID string) string { + botID = strings.TrimSpace(botID) + roomID = strings.TrimSpace(roomID) + if botID == "" || roomID == "" { + return "" + } + b.mu.Lock() + defer b.mu.Unlock() + scope, ok := b.threadScope[botID][roomID] + if !ok { + return "" + } + if scope.UpdatedAt.IsZero() || time.Since(scope.UpdatedAt) > botThreadScopeTTL { + delete(b.threadScope[botID], roomID) + if len(b.threadScope[botID]) == 0 { + delete(b.threadScope, botID) + } + return "" + } + return strings.TrimSpace(scope.RootID) +} + func (b *BotBridge) Requeue(botID string, evt BotEvent) { botID = strings.TrimSpace(botID) if botID == "" { @@ -201,6 +293,7 @@ func (b *BotBridge) markInflightLocked(botID string, evt BotEvent) { if messageID == "" || b.hasSeenLocked(botID, messageID) { return } + b.rememberThreadScopeLocked(botID, evt) if b.inflight[botID] == nil { b.inflight[botID] = make(map[string]BotEvent) } @@ -208,6 +301,31 @@ func (b *BotBridge) markInflightLocked(botID string, evt BotEvent) { b.removePendingLocked(botID, messageID) } +func (b *BotBridge) rememberThreadScopeLocked(botID string, evt BotEvent) { + botID = strings.TrimSpace(botID) + roomID := strings.TrimSpace(evt.RoomID) + if botID == "" || roomID == "" { + return + } + rootID := strings.TrimSpace(evt.ThreadRootID) + if rootID == "" { + if scopes := b.threadScope[botID]; scopes != nil { + delete(scopes, roomID) + if len(scopes) == 0 { + delete(b.threadScope, botID) + } + } + return + } + if b.threadScope[botID] == nil { + b.threadScope[botID] = make(map[string]botThreadScope) + } + b.threadScope[botID][roomID] = botThreadScope{ + RootID: rootID, + UpdatedAt: time.Now().UTC(), + } +} + func (b *BotBridge) hasSeenOrInflightLocked(botID, messageID string) bool { messageID = strings.TrimSpace(messageID) if messageID == "" { @@ -273,21 +391,132 @@ func (b *BotBridge) markSeenLocked(botID, messageID string) { func messageEventForBot(room Room, sender User, message Message, botID string) BotEvent { threadRootID := threadRootID(message) + chatType := chatTypeForRoom(room) + mentions := mentionsForBot(message.Mentions, botID) + text := textForBotEvent(message, botID) return BotEvent{ MessageID: message.ID, RoomID: room.ID, - ChatType: chatTypeForRoom(room), + Channel: "csgclaw", + ChatID: room.ID, + ChatType: chatType, ThreadRootID: threadRootID, Sender: BotSender{ ID: sender.ID, Username: sender.Handle, DisplayName: sender.Name, }, - Text: message.Content, + SenderID: sender.ID, + Text: text, Timestamp: fmt.Sprintf("%d", message.CreatedAt.UnixMilli()), - Mentions: mentionsForBot(message.Mentions, botID), + Mentions: mentions, ThreadContext: botThreadContext(room, threadRootID), + Context: BotMessageContext{ + Channel: "csgclaw", + Account: strings.TrimSpace(botID), + ChatID: room.ID, + ChatType: chatType, + TopicID: threadRootID, + SenderID: sender.ID, + MessageID: message.ID, + Mentioned: len(mentions) > 0, + Raw: map[string]string{ + "room_id": room.ID, + "thread_root_id": threadRootID, + }, + }, + } +} + +func textForBotEvent(message Message, botID string) string { + content := message.Content + botID = strings.TrimSpace(botID) + if content == "" || botID == "" || hasMentionTagForUser(content, botID) { + return content + } + for _, mention := range message.Mentions { + if strings.TrimSpace(mention.ID) == botID { + return replaceMentionHandleWithTag(content, mention) + } + } + return content +} + +func hasMentionTagForUser(content, userID string) bool { + userID = strings.TrimSpace(userID) + if userID == "" { + return false + } + for _, match := range mentionTagPattern.FindAllStringSubmatch(content, -1) { + if len(match) > 1 && strings.TrimSpace(match[1]) == userID { + return true + } + } + return false +} + +func replaceMentionHandleWithTag(content string, mention Mention) string { + candidates := mentionHandleCandidates(mention) + if len(candidates) == 0 { + return content + } + tag := fmt.Sprintf(`%s`, strings.TrimSpace(mention.ID), mentionDisplayName(mention)) + matches := mentionPattern.FindAllStringSubmatchIndex(content, -1) + if len(matches) == 0 { + return content + } + + var out strings.Builder + last := 0 + replaced := false + for _, match := range matches { + if len(match) < 6 || match[4] < 0 || match[5] < 0 { + continue + } + handle := strings.ToLower(strings.TrimSpace(content[match[4]:match[5]])) + if _, ok := candidates[handle]; !ok { + continue + } + replaced = true + out.WriteString(content[last:match[0]]) + if match[2] >= 0 && match[3] >= 0 { + out.WriteString(content[match[2]:match[3]]) + } + out.WriteString(tag) + last = match[1] + } + if !replaced { + return content + } + out.WriteString(content[last:]) + return out.String() +} + +func mentionHandleCandidates(mention Mention) map[string]struct{} { + candidates := make(map[string]struct{}, 2) + if name := normalizeMentionHandle(mention.Name); name != "" { + candidates[name] = struct{}{} + } + if idHandle := strings.TrimPrefix(strings.TrimSpace(mention.ID), "u-"); idHandle != "" { + candidates[strings.ToLower(idHandle)] = struct{}{} + } + return candidates +} + +func normalizeMentionHandle(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.TrimPrefix(value, "@") + return value +} + +func mentionDisplayName(mention Mention) string { + if name := strings.TrimSpace(mention.Name); name != "" { + return name + } + if id := strings.TrimSpace(mention.ID); id != "" { + return id } + return "user" } func botThreadContext(room Room, rootMessageID string) *BotThreadContext { diff --git a/internal/im/bot_bridge_test.go b/internal/im/bot_bridge_test.go index 488a5f03..23bcee43 100644 --- a/internal/im/bot_bridge_test.go +++ b/internal/im/bot_bridge_test.go @@ -96,7 +96,7 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { root := Message{ ID: "msg-root", - SenderID: "u-admin", + SenderID: "u-ux", Content: "root context", CreatedAt: time.Now().UTC(), } @@ -105,6 +105,7 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { SenderID: "u-admin", Content: "thread reply", CreatedAt: time.Now().UTC().Add(time.Second), + Mentions: []Mention{{ID: "u-bot", Name: "bot"}}, RelatesTo: &MessageRelation{ RelType: RelationTypeThread, EventID: root.ID, @@ -113,7 +114,7 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { room := Room{ ID: "room-group", IsDirect: false, - Members: []string{"u-admin", "u-bot"}, + Members: []string{"u-admin", "u-ux", "u-bot"}, Messages: []Message{root, reply}, Threads: []ThreadState{{ RootMessageID: root.ID, @@ -133,6 +134,20 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { if evt.ThreadRootID != root.ID { t.Fatalf("ThreadRootID = %q, want %q", evt.ThreadRootID, root.ID) } + if len(evt.Mentions) != 1 || evt.Mentions[0] != "u-bot" { + t.Fatalf("Mentions = %+v, want [u-bot]", evt.Mentions) + } + if evt.Channel != "csgclaw" || evt.ChatID != room.ID { + t.Fatalf("PicoClaw event address = channel %q chat_id %q, want csgclaw %q", evt.Channel, evt.ChatID, room.ID) + } + if evt.Context.Channel != "csgclaw" || + evt.Context.ChatID != room.ID || + evt.Context.ChatType != "group" || + evt.Context.TopicID != root.ID || + evt.Context.MessageID != reply.ID || + evt.Context.SenderID != sender.ID { + t.Fatalf("PicoClaw context = %+v, want thread topic context", evt.Context) + } if evt.ThreadContext == nil || evt.ThreadContext.RootMessageID != root.ID || len(evt.ThreadContext.Context) != 1 { t.Fatalf("ThreadContext = %+v, want root context", evt.ThreadContext) } @@ -141,6 +156,58 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { } } +func TestPublishMessageEventNormalizesPlainThreadMentionForPicoClaw(t *testing.T) { + bridge := NewBotBridge("") + events, cancel := bridge.Subscribe("u-qa") + defer cancel() + + root := Message{ + ID: "msg-root", + SenderID: "u-manager", + Content: "root context", + CreatedAt: time.Now().UTC(), + } + reply := Message{ + ID: "msg-reply", + SenderID: "u-admin", + Content: "@qa please check this", + CreatedAt: time.Now().UTC().Add(time.Second), + Mentions: []Mention{{ID: "u-qa", Name: "qa"}}, + RelatesTo: &MessageRelation{ + RelType: RelationTypeThread, + EventID: root.ID, + }, + } + room := Room{ + ID: "room-group", + IsDirect: false, + Members: []string{"u-admin", "u-manager", "u-qa"}, + Messages: []Message{root, reply}, + Threads: []ThreadState{{ + RootMessageID: root.ID, + Context: []Message{root}, + }}, + } + sender := User{ID: "u-admin", Name: "Admin", Handle: "admin"} + + bridge.PublishMessageEvent(room, sender, reply) + + select { + case evt := <-events: + if evt.ThreadRootID != root.ID { + t.Fatalf("ThreadRootID = %q, want %q", evt.ThreadRootID, root.ID) + } + if evt.Text != `qa please check this` { + t.Fatalf("Text = %q, want PicoClaw mention tag", evt.Text) + } + if len(evt.Mentions) != 1 || evt.Mentions[0] != "u-qa" || !evt.Context.Mentioned { + t.Fatalf("Mentions = %+v context = %+v, want u-qa mentioned", evt.Mentions, evt.Context) + } + case <-time.After(time.Second): + t.Fatal("PublishMessageEvent() timed out waiting for event") + } +} + func TestPublishMessageEventQueuesUntilBotSubscribes(t *testing.T) { bridge := NewBotBridge("") room := Room{ diff --git a/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json b/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json index 591ec1f3..5fd05b55 100644 --- a/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json +++ b/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json @@ -1,6 +1,10 @@ { "session": { - "dm_scope": "per-channel-peer" + "dm_scope": "per-channel-peer", + "dimensions": [ + "chat", + "topic" + ] }, "version": 1, "agents": { diff --git a/web/app/src/hooks/workspace/useConversationController.ts b/web/app/src/hooks/workspace/useConversationController.ts index 7f01cb22..362b82fd 100644 --- a/web/app/src/hooks/workspace/useConversationController.ts +++ b/web/app/src/hooks/workspace/useConversationController.ts @@ -27,6 +27,7 @@ import { } from "@/models/conversations"; import { areComposerSegmentsEqual, + getMentionCandidates, getComposerMentionState, insertComposerLineBreak, isComposerKeyboardEventComposing, @@ -182,14 +183,10 @@ export function useConversationController({ return []; } const allowed = new Set(activeConversation?.members ?? []); - return data.users - .filter((user) => allowed.has(user.id)) - .filter( - (user) => - user.handle.toLowerCase().includes(composerMentionState.query.toLowerCase()) || - user.name.toLowerCase().includes(composerMentionState.query.toLowerCase()), - ) - .slice(0, 5); + return getMentionCandidates( + data.users.filter((user) => allowed.has(user.id)), + composerMentionState.query, + ); }, [data, activeConversation, composerMentionState]); const mentionableUsersByHandle = useMemo(() => { const result = new Map(); diff --git a/web/app/src/models/composer.ts b/web/app/src/models/composer.ts index bd9e1531..2dbf09df 100644 --- a/web/app/src/models/composer.ts +++ b/web/app/src/models/composer.ts @@ -235,6 +235,24 @@ export function normalizeTextMentions(segments, mentionableUsersByHandle) { return normalizeComposerSegments(normalized); } +export function getMentionCandidates(users, query, options: { limit?: number } = {}) { + const normalizedQuery = String(query ?? "") + .trim() + .toLowerCase(); + const validUsers = (users ?? []).filter((user) => user?.id); + if (!normalizedQuery) { + return validUsers; + } + const limit = Number.isFinite(options.limit) ? options.limit : 5; + return validUsers + .filter((user) => { + const handle = String(user.handle ?? "").toLowerCase(); + const name = String(user.name ?? "").toLowerCase(); + return handle.includes(normalizedQuery) || name.includes(normalizedQuery); + }) + .slice(0, limit); +} + export function getComposerMentionState(root) { if (!root) { return null; diff --git a/web/app/src/models/conversations.ts b/web/app/src/models/conversations.ts index 6e8109cb..c393a679 100644 --- a/web/app/src/models/conversations.ts +++ b/web/app/src/models/conversations.ts @@ -356,7 +356,6 @@ export function formatTime(value: string | number | Date | null | undefined, loc return new Date(value).toLocaleTimeString(locale === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit", - timeZone: locale === "zh" ? "Asia/Shanghai" : "UTC", }); } diff --git a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css index 0df91a2b..ac11f17e 100644 --- a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css +++ b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.css @@ -392,6 +392,11 @@ left: 24px; bottom: 124px; width: 280px; + max-height: min(360px, 48vh); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--portal-gray-400) transparent; padding: 8px; background: rgba(255, 255, 255, 0.96); border: 1px solid rgba(148, 163, 184, 0.24); @@ -400,6 +405,20 @@ animation: rise-in 180ms ease both; } +.mention-picker::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.mention-picker::-webkit-scrollbar-track { + background: transparent; +} + +.mention-picker::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in oklch, var(--portal-gray-400) 70%, transparent); +} + .mention-option { width: 100%; display: flex; diff --git a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx index 1807400c..a76ae027 100644 --- a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx +++ b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { X } from "lucide-react"; import { CLIProxyAuthControl } from "@/components/business/ProfileControls"; import { MessageContent } from "@/components/business/MessageContent"; @@ -7,6 +7,7 @@ import { AddUserIcon, IconImage, TrashIcon, UsersIcon, WrenchIcon } from "@/comp import { insertComposerSegmentsAtSelection, insertPlainTextAtSelection, + getMentionCandidates, normalizeTextMentions, } from "@/models/composer"; import { normalizeAuthProviderName, providerNeedsAuth } from "@/models/agents"; @@ -21,6 +22,12 @@ import { } from "@/models/conversations"; import { localizeRole } from "@/shared/i18n"; +type ThreadMentionState = { + end: number; + query: string; + start: number; +}; + export function ConversationPane({ conversation, visibleMessages, @@ -300,28 +307,7 @@ export function ConversationPane({