clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinyclient / chat-activity.go
chat-activity.go 7.3K
1package tinyclient23import (4 "bytes"5 "context"6 "encoding/json"7 "net/http"8 "net/url"9 "sort"10 "strings"11 "time"12)1314type ChatActivityReader interface {15 ReadChatActivity(context.Context, string, string, string) (any, error)16}1718type chatActivityItem struct {19 Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string20 CreatedAt, Expires int6421 Answer string22 CanDecide, Question, Encrypted bool23 Native, Multiple bool24 Freeform bool25 Interaction string26 Options []chatActivityOption27 Result *chatActivityResult28}29type chatActivityOption struct{ ID, Label string }30type chatActivityResult struct{ ID, Content string }31type chatActivityPage struct {32 Items []chatActivityItem33 Endpoint, Room, Actor, Error string34}3536func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {37 items := []chatActivityItem{}38 for _, raw := range collaborationSlice(jobs, "items") {39 item := valueMap(raw)40 id := plainString(item["id"])41 if !eventIDPattern.MatchString(id) {42 continue43 }44 ev := valueMap(item["event"])45 title := firstString(tagValues(ev, "subject"))46 if title == "" {47 title = strings.TrimSpace(plainString(item["content"]))48 }49 if title == "" {50 for _, raw := range collaborationSlice(item, "inputs") {51 input := valueMap(raw)52 if plainString(input["type"]) == "text" {53 title = plainString(input["data"])54 break55 }56 }57 }58 if title == "" {59 title = "Agent task"60 }61 title = truncateActivity(title, 100)62 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"])}63 row.Encrypted, _ = item["encrypted"].(bool)64 if row.Encrypted {65 row.Title = "Encrypted task"66 row.Content = "Open the task in its originating client."67 }68 if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted {69 row.Author = plainString(feedback["provider"])70 row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"]))71 }72 if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {73 row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}74 row.Status = "success"75 if row.Encrypted {76 row.Result.Content = ""77 }78 }79 if row.Status == "" {80 row.Status = "queued"81 }82 items = append(items, row)83 }84 for _, raw := range collaborationSlice(approvals, "items") {85 item := valueMap(raw)86 id := plainString(item["id"])87 if !eventIDPattern.MatchString(id) || actor == "" {88 continue89 }90 asker := plainString(item["asker"])91 can := approvalActorMayDecide(item, actor)92 if !can && asker != actor {93 continue94 }95 title := strings.TrimSpace(plainString(item["subject"]))96 if title == "" {97 title = plainString(item["content"])98 }99 state := plainString(item["state"])100 expires := unixSeconds(item["expires"])101 if state == "open" && expires > 0 && expires <= time.Now().Unix() {102 state = "expired"103 }104 answer := valueMap(item["answer"])105 decision := plainString(answer["decision"])106 row := 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}107 rawOptions := collaborationSlice(item, "options")108 if plainString(item["selection"]) != "" && (len(rawOptions) > 0 || eventHasTag(item, "tinyagent")) {109 row.Native = true110 row.Interaction = plainString(item["interaction"])111 row.Question = row.Interaction == "question"112 row.Multiple = plainString(item["selection"]) == "multiple"113 row.Freeform = plainString(item["freeform"]) == "true" || plainString(item["selection"]) == "text"114 for _, rawOption := range rawOptions {115 option := valueMap(rawOption)116 row.Options = append(row.Options, chatActivityOption{ID: plainString(option["id"]), Label: plainString(option["label"])})117 }118 }119 items = append(items, row)120 }121 sort.SliceStable(items, func(i, j int) bool {122 if items[i].CreatedAt == items[j].CreatedAt {123 return items[i].ID < items[j].ID124 }125 return items[i].CreatedAt < items[j].CreatedAt126 })127 return items128}129130func eventHasTag(item map[string]any, name string) bool {131 for _, tag := range tagValues(item["event"], name) {132 if tag == "1" {133 return true134 }135 }136 return false137}138func truncateActivity(value string, max int) string {139 runes := []rune(value)140 if len(runes) > max {141 return string(runes[:max]) + "…"142 }143 return value144}145func approvalActorMayDecide(item map[string]any, actor string) bool {146 if actor == "" || plainString(item["asker"]) == actor {147 return false148 }149 for _, key := range stringValues(item["asked"]) {150 if key == actor {151 return true152 }153 }154 return false155}156func stringValues(value any) []string {157 raw, err := json.Marshal(value)158 if err != nil {159 return nil160 }161 var values []string162 if json.Unmarshal(raw, &values) != nil {163 return nil164 }165 return values166}167168func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {169 endpoint := "/chat/activity?room=" + url.QueryEscape(room)170 if root != "" {171 endpoint += "&root=" + url.QueryEscape(root)172 }173 page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}174 reader, ok := a.backend.(ChatActivityReader)175 if !ok {176 return page177 }178 result, err := reader.ReadChatActivity(ctx, actor, room, root)179 if err != nil {180 page.Error = "Agent activity is unavailable."181 return page182 }183 data := valueMap(result)184 page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)185 return page186}187func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {188 w.Header().Set("Cache-Control", "private, no-store")189 actor, err := a.resolveActor(r)190 if err != nil {191 http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)192 return193 }194 room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")195 if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {196 http.Error(w, "Invalid room or thread.", http.StatusBadRequest)197 return198 }199 page := a.chatActivity(r.Context(), actor, room, root)200 if page.Error != "" {201 http.Error(w, page.Error, http.StatusForbidden)202 return203 }204 w.Header().Set("Content-Type", "text/html; charset=utf-8")205 var body bytes.Buffer206 if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {207 http.Error(w, "Activity rendering failed: "+err.Error(), 500)208 return209 }210 _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))211}