Skip to content

Commit c190a53

Browse files
committed
feat: web console Messages tab + localStorage persistence
- Replace Mail tab with Messages (thread-based view) in social modal - Remove message title header, wrap preview content (no ellipsis truncation) - Add localStorage cache-first loading for Messages and Feed tabs: threads cached by thread_id, moments by id, max 100 items each - Fix nonce sync guard: rpc_prepare_inscription self-heals stale genesis_nonce - Update chat.go keyword hints and tool allowlist for new social APIs
1 parent 5b031fa commit c190a53

File tree

8 files changed

+306
-255
lines changed

8 files changed

+306
-255
lines changed

internal/market/client.go

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -229,32 +229,53 @@ func (c *Client) GetNearbyMiners(ctx context.Context, tokenID int) ([]NearbyMine
229229
return result, nil
230230
}
231231

232-
// SendMail sends a letter to a friend agent. Requires auth.
233-
func (c *Client) SendMail(ctx context.Context, recipientID, subject, content string) error {
234-
body := map[string]any{
235-
"module": "mail",
236-
"recipient_id": recipientID,
237-
"subject": subject,
238-
"content": content,
232+
// GetOrCreateDm creates or retrieves a DM thread with the target agent. Requires auth.
233+
func (c *Client) GetOrCreateDm(ctx context.Context, targetID string) (string, error) {
234+
body := map[string]any{"module": "thread", "action": "create_dm", "target_id": targetID}
235+
var result struct {
236+
ThreadID string `json:"thread_id"`
237+
}
238+
if err := c.post(ctx, "/skill/social", body, &result); err != nil {
239+
return "", err
239240
}
241+
return result.ThreadID, nil
242+
}
243+
244+
// SendThreadMessage posts a message to an existing thread. Requires auth.
245+
func (c *Client) SendThreadMessage(ctx context.Context, threadID, body string) error {
246+
req := map[string]any{"module": "thread", "action": "send", "thread_id": threadID, "body": body}
240247
var resp map[string]any
241-
return c.post(ctx, "/skill/social", body, &resp)
248+
return c.post(ctx, "/skill/social", req, &resp)
242249
}
243250

244-
// ReadMail fetches inbox, outbox, or a specific letter. Requires auth.
245-
// box: "inbox" | "outbox"; letterID: specific letter ID (pass "" to list).
246-
func (c *Client) ReadMail(ctx context.Context, box, letterID string) ([]MailMessage, error) {
247-
var url string
248-
if letterID != "" {
249-
url = "/skill/social?module=mail&id=" + letterID
250-
} else {
251-
url = "/skill/social?module=mail&box=" + box
251+
// ListThreads returns the agent's message threads. Requires auth.
252+
func (c *Client) ListThreads(ctx context.Context) ([]Thread, error) {
253+
var result struct {
254+
Threads []Thread `json:"threads"`
255+
}
256+
if err := c.get(ctx, "/skill/social?module=thread&action=list", true, &result); err != nil {
257+
return nil, err
252258
}
253-
var result []MailMessage
259+
return result.Threads, nil
260+
}
261+
262+
// GetThreadMessages returns messages in a thread. Requires auth.
263+
func (c *Client) GetThreadMessages(ctx context.Context, threadID string) ([]ThreadMessage, error) {
264+
var result struct {
265+
Messages []ThreadMessage `json:"messages"`
266+
}
267+
url := "/skill/social?module=thread&action=messages&thread_id=" + threadID
254268
if err := c.get(ctx, url, true, &result); err != nil {
255269
return nil, err
256270
}
257-
return result, nil
271+
return result.Messages, nil
272+
}
273+
274+
// MarkThreadRead marks a thread as read. Requires auth.
275+
func (c *Client) MarkThreadRead(ctx context.Context, threadID string) error {
276+
body := map[string]any{"module": "thread", "action": "mark_read", "thread_id": threadID}
277+
var resp map[string]any
278+
return c.post(ctx, "/skill/social", body, &resp)
258279
}
259280

260281
// GetMoments reads the moments feed. Requires auth.

internal/market/types.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -243,15 +243,25 @@ type NearbyMiner struct {
243243
TrustScore int `json:"trust_score"`
244244
}
245245

246-
// MailMessage is one letter from GET /skill/social?module=mail.
247-
type MailMessage struct {
248-
ID string `json:"id"`
249-
SenderID string `json:"sender_id"`
250-
RecipientID string `json:"recipient_id"`
251-
Subject string `json:"subject"`
252-
Content string `json:"content"`
253-
Read bool `json:"read"`
254-
CreatedAt string `json:"created_at"`
246+
// Thread is one entry from GET /skill/social?module=thread&action=list.
247+
type Thread struct {
248+
ThreadID string `json:"thread_id"`
249+
Type string `json:"type"`
250+
Title string `json:"title"`
251+
LastMessage string `json:"last_message"`
252+
LastMessageAt string `json:"last_message_at"`
253+
UnreadCount int `json:"unread_count"`
254+
// DM-only
255+
OtherAgentID string `json:"other_agent_id"`
256+
}
257+
258+
// ThreadMessage is one message from GET /skill/social?module=thread&action=messages.
259+
type ThreadMessage struct {
260+
MessageID string `json:"message_id"`
261+
FromAgentID string `json:"from_agent_id"`
262+
FromDisplayName string `json:"from_display_name"`
263+
Body string `json:"body"`
264+
CreatedAt string `json:"created_at"`
255265
}
256266

257267
// MomentItem is one moment from GET /skill/social?module=moments.

internal/tools/social.go

Lines changed: 82 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -101,118 +101,144 @@ func (t *socialNearbyTool) Call(ctx context.Context, argsJSON string) string {
101101
return sb.String()
102102
}
103103

104-
// ── social_mail_send ──────────────────────────────────────────────────────────
104+
// ── social_dm_send ────────────────────────────────────────────────────────────
105105

106-
type socialMailSendTool struct{ client *market.Client }
106+
type socialDmSendTool struct{ client *market.Client }
107107

108-
func NewSocialMailSendTool(mc *market.Client) Tool { return &socialMailSendTool{client: mc} }
108+
func NewSocialDmSendTool(mc *market.Client) Tool { return &socialDmSendTool{client: mc} }
109109

110-
func (t *socialMailSendTool) Def() ToolDef {
110+
func (t *socialDmSendTool) Def() ToolDef {
111111
return ToolDef{
112-
Name: "social_mail_send",
113-
Description: "Send a letter to a friend agent. You must follow the recipient first.",
112+
Name: "social_dm_send",
113+
Description: "Send a direct message to another agent. Automatically creates or reuses the DM thread.",
114114
Parameters: ToolParameters{
115115
Type: "object",
116116
Properties: map[string]ToolProperty{
117117
"recipient_id": {
118118
Type: "string",
119119
Description: "The agent ID of the recipient.",
120120
},
121-
"subject": {
121+
"body": {
122122
Type: "string",
123-
Description: "Subject line (max 100 chars).",
124-
},
125-
"content": {
126-
Type: "string",
127-
Description: "Letter body (max 2000 chars).",
123+
Description: "Message content (max 4000 chars).",
128124
},
129125
},
130-
Required: []string{"recipient_id", "subject", "content"},
126+
Required: []string{"recipient_id", "body"},
131127
},
132128
}
133129
}
134130

135-
func (t *socialMailSendTool) Call(ctx context.Context, argsJSON string) string {
131+
func (t *socialDmSendTool) Call(ctx context.Context, argsJSON string) string {
136132
var args struct {
137133
RecipientID string `json:"recipient_id"`
138-
Subject string `json:"subject"`
139-
Content string `json:"content"`
134+
Body string `json:"body"`
140135
}
141136
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
142137
return "error: invalid arguments: " + err.Error()
143138
}
144139
if strings.TrimSpace(args.RecipientID) == "" {
145140
return "error: recipient_id is required"
146141
}
147-
if strings.TrimSpace(args.Subject) == "" {
148-
return "error: subject is required"
142+
if strings.TrimSpace(args.Body) == "" {
143+
return "error: body is required"
149144
}
150-
if strings.TrimSpace(args.Content) == "" {
151-
return "error: content is required"
145+
threadID, err := t.client.GetOrCreateDm(ctx, args.RecipientID)
146+
if err != nil {
147+
return fmt.Sprintf("error creating DM thread with %s: %s", args.RecipientID, err)
152148
}
153-
if err := t.client.SendMail(ctx, args.RecipientID, args.Subject, args.Content); err != nil {
154-
return fmt.Sprintf("error sending mail to %s: %s", args.RecipientID, err)
149+
if err := t.client.SendThreadMessage(ctx, threadID, args.Body); err != nil {
150+
return fmt.Sprintf("error sending message: %s", err)
155151
}
156-
return fmt.Sprintf("Letter sent to %s.", args.RecipientID)
152+
return fmt.Sprintf("Message sent to %s.", args.RecipientID)
157153
}
158154

159-
// ── social_mail_read ──────────────────────────────────────────────────────────
155+
// ── social_messages_list ──────────────────────────────────────────────────────
160156

161-
type socialMailReadTool struct{ client *market.Client }
157+
type socialMessagesListTool struct{ client *market.Client }
162158

163-
func NewSocialMailReadTool(mc *market.Client) Tool { return &socialMailReadTool{client: mc} }
159+
func NewSocialMessagesListTool(mc *market.Client) Tool { return &socialMessagesListTool{client: mc} }
164160

165-
func (t *socialMailReadTool) Def() ToolDef {
161+
func (t *socialMessagesListTool) Def() ToolDef {
166162
return ToolDef{
167-
Name: "social_mail_read",
168-
Description: "Read your mail. Use box=inbox to see received letters, box=outbox for sent. Pass letter_id to read a specific letter.",
163+
Name: "social_messages_list",
164+
Description: "List your message threads (DMs and group chats). Shows the most recent message and unread count for each.",
165+
Parameters: ToolParameters{Type: "object", Properties: map[string]ToolProperty{}},
166+
}
167+
}
168+
169+
func (t *socialMessagesListTool) Call(ctx context.Context, _ string) string {
170+
threads, err := t.client.ListThreads(ctx)
171+
if err != nil {
172+
return fmt.Sprintf("error listing threads: %s", err)
173+
}
174+
if len(threads) == 0 {
175+
return "No message threads yet."
176+
}
177+
var sb strings.Builder
178+
sb.WriteString(fmt.Sprintf("=== Messages (%d threads) ===\n", len(threads)))
179+
for _, th := range threads {
180+
unread := ""
181+
if th.UnreadCount > 0 {
182+
unread = fmt.Sprintf(" [%d unread]", th.UnreadCount)
183+
}
184+
name := th.Title
185+
if name == "" && th.OtherAgentID != "" {
186+
name = th.OtherAgentID
187+
}
188+
preview := th.LastMessage
189+
if len(preview) > 80 {
190+
preview = preview[:80] + "…"
191+
}
192+
sb.WriteString(fmt.Sprintf("- [%s] %s%s\n %s\n", th.ThreadID[:8], name, unread, preview))
193+
}
194+
return strings.TrimRight(sb.String(), "\n")
195+
}
196+
197+
// ── social_messages_read ──────────────────────────────────────────────────────
198+
199+
type socialMessagesReadTool struct{ client *market.Client }
200+
201+
func NewSocialMessagesReadTool(mc *market.Client) Tool { return &socialMessagesReadTool{client: mc} }
202+
203+
func (t *socialMessagesReadTool) Def() ToolDef {
204+
return ToolDef{
205+
Name: "social_messages_read",
206+
Description: "Read messages in a thread. Use social_messages_list first to get thread IDs.",
169207
Parameters: ToolParameters{
170208
Type: "object",
171209
Properties: map[string]ToolProperty{
172-
"box": {
173-
Type: "string",
174-
Description: "Which mailbox to read: 'inbox' or 'outbox'. Ignored when letter_id is set.",
175-
},
176-
"letter_id": {
210+
"thread_id": {
177211
Type: "string",
178-
Description: "ID of a specific letter to read (marks it as read).",
212+
Description: "Thread UUID from social_messages_list.",
179213
},
180214
},
215+
Required: []string{"thread_id"},
181216
},
182217
}
183218
}
184219

185-
func (t *socialMailReadTool) Call(ctx context.Context, argsJSON string) string {
220+
func (t *socialMessagesReadTool) Call(ctx context.Context, argsJSON string) string {
186221
var args struct {
187-
Box string `json:"box"`
188-
LetterID string `json:"letter_id"`
222+
ThreadID string `json:"thread_id"`
189223
}
190224
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
191225
return "error: invalid arguments: " + err.Error()
192226
}
193-
box := args.Box
194-
if box == "" && args.LetterID == "" {
195-
box = "inbox"
227+
if strings.TrimSpace(args.ThreadID) == "" {
228+
return "error: thread_id is required"
196229
}
197-
letters, err := t.client.ReadMail(ctx, box, args.LetterID)
230+
msgs, err := t.client.GetThreadMessages(ctx, args.ThreadID)
198231
if err != nil {
199-
return fmt.Sprintf("error reading mail: %s", err)
232+
return fmt.Sprintf("error reading messages: %s", err)
200233
}
201-
if len(letters) == 0 {
202-
return "No letters found."
234+
if len(msgs) == 0 {
235+
return "No messages in this thread."
203236
}
237+
// Mark as read (fire-and-forget)
238+
go func() { _ = t.client.MarkThreadRead(ctx, args.ThreadID) }()
204239
var sb strings.Builder
205-
for _, l := range letters {
206-
readMark := ""
207-
if !l.Read {
208-
readMark = " [UNREAD]"
209-
}
210-
sb.WriteString(fmt.Sprintf("--- From: %s | ID: %s%s ---\n", l.SenderID, l.ID, readMark))
211-
sb.WriteString(fmt.Sprintf("Subject: %s\n", l.Subject))
212-
if l.Content != "" {
213-
sb.WriteString(fmt.Sprintf("%s\n", l.Content))
214-
}
215-
sb.WriteString("\n")
240+
for _, m := range msgs {
241+
sb.WriteString(fmt.Sprintf("[%s | %s]\n%s\n\n", m.FromAgentID, m.CreatedAt[:16], m.Body))
216242
}
217243
return strings.TrimRight(sb.String(), "\n")
218244
}

internal/web/chat.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,9 @@ func (s *ChatSession) buildToolList() []tools.Tool {
220220
list = append(list, tools.NewSocialFollowTool(s.marketClient))
221221
list = append(list, tools.NewSocialUnfollowTool(s.marketClient))
222222
list = append(list, tools.NewSocialNearbyTool(s.marketClient))
223-
list = append(list, tools.NewSocialMailSendTool(s.marketClient))
224-
list = append(list, tools.NewSocialMailReadTool(s.marketClient))
223+
list = append(list, tools.NewSocialDmSendTool(s.marketClient))
224+
list = append(list, tools.NewSocialMessagesListTool(s.marketClient))
225+
list = append(list, tools.NewSocialMessagesReadTool(s.marketClient))
225226
list = append(list, tools.NewSocialMomentsReadTool(s.marketClient))
226227
list = append(list, tools.NewSocialLikeMomentTool(s.marketClient))
227228
list = append(list, tools.NewSocialConnectionsTool(s.marketClient))
@@ -653,15 +654,15 @@ var toolKeywords = []string{
653654
// data
654655
"json", "csv", "parse", "search", "find", "grep",
655656
// social / platform actions
656-
"nearby", "friends", "followers", "following", "mail", "moment", "follow", "post",
657+
"nearby", "friends", "followers", "following", "message", "thread", "moment", "follow", "post",
657658
// skill market
658659
"skill", "buy", "purchase", "publish", "listing", "bounty", "request", "rate", "dispute",
659660
"技能", "购买", "发布", "挂单", "赏金", "评分", "争议",
660661
// zh-cn tool/action hints
661662
"接口", "请求", "链接", "网页", "抓取", "获取", "下载", "查询",
662663
"文件", "目录", "读取", "写入", "创建", "删除", "移动", "路径",
663664
"执行", "命令", "脚本", "运行", "搜索", "解析", "数据", "附近矿工",
664-
"好友", "关注", "粉丝", "邮件", "动态", "发帖",
665+
"好友", "关注", "粉丝", "消息", "私信", "动态", "发帖",
665666
}
666667

667668
func mightNeedTools(msg string) bool {
@@ -693,7 +694,7 @@ func shouldRetryWithTools(_ string, reply string) bool {
693694
"skill_buy", "skill_my_purchases", "skill_my_skills",
694695
"skill_requests", "skill_request_claim", "cw_market_mine",
695696
"social_post", "social_follow", "social_unfollow", "social_nearby",
696-
"social_mail_send", "social_mail_read", "social_moments_read",
697+
"social_dm_send", "social_messages_list", "social_messages_read", "social_moments_read",
697698
"social_like_moment", "social_connections",
698699
"cw_balance", "cw_transfer", "cw_history",
699700
"report_issue",
@@ -794,8 +795,9 @@ func ChatSystemPrompt(soul string) string {
794795
sb.WriteString("- social_follow: Follow another agent by ID.\n")
795796
sb.WriteString("- social_unfollow: Unfollow an agent.\n")
796797
sb.WriteString("- social_nearby: Find agents mining the same NFT token (great for finding friends).\n")
797-
sb.WriteString("- social_mail_send: Send a letter to a friend agent (must follow them first).\n")
798-
sb.WriteString("- social_mail_read: Read your inbox or outbox, or a specific letter by ID.\n")
798+
sb.WriteString("- social_dm_send: Send a direct message to another agent (must follow them first).\n")
799+
sb.WriteString("- social_messages_list: List your message threads (DMs and group chats) with unread counts.\n")
800+
sb.WriteString("- social_messages_read: Read messages in a specific thread by thread ID.\n")
799801
sb.WriteString("- social_moments_read: Read the public or friends moments feed, or a specific agent's moments.\n")
800802
sb.WriteString("- social_like_moment: Like a moment (must be friends with the author).\n")
801803
sb.WriteString("- social_connections: View your friends (mutual), following, or followers list.\n")

0 commit comments

Comments
 (0)