tinyrelayfile
tinyrelay/tinyclient/chat-activity.go/

tinyclient/chat-activity.go raw
1package tinyclient
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "net/url"
9 "sort"
10 "strings"
11 "time"
12)
13
14type ChatActivityReader interface {
15 ReadChatActivity(context.Context, string, string, string) (any, error)
16}
17
18type chatActivityItem struct {
19 Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string
20 CreatedAt, Expires int64
21 Answer string
22 CanDecide, Question, Encrypted bool
23 Result *chatActivityResult
24}
25type chatActivityResult struct{ ID, Content string }
26type chatActivityPage struct {
27 Items []chatActivityItem
28 Endpoint, Room, Actor, Error string
29}
30
31func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {
32 items := []chatActivityItem{}
33 for _, raw := range collaborationSlice(jobs, "items") {
34 item := valueMap(raw)
35 id := plainString(item["id"])
36 if !eventIDPattern.MatchString(id) {
37 continue
38 }
39 ev := valueMap(item["event"])
40 title := firstString(tagValues(ev, "subject"))
41 if title == "" {
42 title = strings.TrimSpace(plainString(item["content"]))
43 }
44 if title == "" {
45 for _, raw := range collaborationSlice(item, "inputs") {
46 input := valueMap(raw)
47 if plainString(input["type"]) == "text" {
48 title = plainString(input["data"])
49 break
50 }
51 }
52 }
53 if title == "" {
54 title = "Agent task"
55 }
56 title = truncateActivity(title, 100)
57 row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: plainString(item["kind"]), State: plainString(item["state"]), Status: plainString(item["status"]), Title: title, Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"])}
58 row.Encrypted, _ = item["encrypted"].(bool)
59 if row.Encrypted {
60 row.Title = "Encrypted task"
61 row.Content = "Open the task in its originating client."
62 }
63 if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted {
64 row.Author = plainString(feedback["provider"])
65 row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"]))
66 }
67 if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {
68 row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}
69 row.Status = "success"
70 if row.Encrypted {
71 row.Result.Content = ""
72 }
73 }
74 if row.Status == "" {
75 row.Status = "queued"
76 }
77 items = append(items, row)
78 }
79 for _, raw := range collaborationSlice(approvals, "items") {
80 item := valueMap(raw)
81 id := plainString(item["id"])
82 if !eventIDPattern.MatchString(id) || actor == "" {
83 continue
84 }
85 asker := plainString(item["asker"])
86 can := approvalActorMayDecide(item, actor)
87 if !can && asker != actor {
88 continue
89 }
90 title := strings.TrimSpace(plainString(item["subject"]))
91 if title == "" {
92 title = plainString(item["content"])
93 }
94 state := plainString(item["state"])
95 expires := unixSeconds(item["expires"])
96 if state == "open" && expires > 0 && expires <= time.Now().Unix() {
97 state = "expired"
98 }
99 answer := valueMap(item["answer"])
100 decision := plainString(answer["decision"])
101 items = append(items, chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision})
102 }
103 sort.SliceStable(items, func(i, j int) bool {
104 if items[i].CreatedAt == items[j].CreatedAt {
105 return items[i].ID < items[j].ID
106 }
107 return items[i].CreatedAt < items[j].CreatedAt
108 })
109 return items
110}
111func truncateActivity(value string, max int) string {
112 runes := []rune(value)
113 if len(runes) > max {
114 return string(runes[:max]) + "…"
115 }
116 return value
117}
118func approvalActorMayDecide(item map[string]any, actor string) bool {
119 if actor == "" || plainString(item["asker"]) == actor {
120 return false
121 }
122 for _, key := range stringValues(item["asked"]) {
123 if key == actor {
124 return true
125 }
126 }
127 return false
128}
129func stringValues(value any) []string {
130 raw, err := json.Marshal(value)
131 if err != nil {
132 return nil
133 }
134 var values []string
135 if json.Unmarshal(raw, &values) != nil {
136 return nil
137 }
138 return values
139}
140
141func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {
142 endpoint := "/chat/activity?room=" + url.QueryEscape(room)
143 if root != "" {
144 endpoint += "&root=" + url.QueryEscape(root)
145 }
146 page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}
147 reader, ok := a.backend.(ChatActivityReader)
148 if !ok {
149 return page
150 }
151 result, err := reader.ReadChatActivity(ctx, actor, room, root)
152 if err != nil {
153 page.Error = "Agent activity is unavailable."
154 return page
155 }
156 data := valueMap(result)
157 page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)
158 return page
159}
160func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {
161 w.Header().Set("Cache-Control", "private, no-store")
162 actor, err := a.resolveActor(r)
163 if err != nil {
164 http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)
165 return
166 }
167 room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")
168 if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {
169 http.Error(w, "Invalid room or thread.", http.StatusBadRequest)
170 return
171 }
172 page := a.chatActivity(r.Context(), actor, room, root)
173 if page.Error != "" {
174 http.Error(w, page.Error, http.StatusForbidden)
175 return
176 }
177 w.Header().Set("Content-Type", "text/html; charset=utf-8")
178 var body bytes.Buffer
179 if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {
180 http.Error(w, "Activity rendering failed.", 500)
181 return
182 }
183 _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))
184}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs