clone

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

feat/ui-updates
tinyrelay / tinyclient / chat-activity.go
chat-activity.go 8.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 row.Answer = nativeActivityAnswer(row.Answer, row.Options)119 }120 items = append(items, row)121 }122 sort.SliceStable(items, func(i, j int) bool {123 if items[i].CreatedAt == items[j].CreatedAt {124 return items[i].ID < items[j].ID125 }126 return items[i].CreatedAt < items[j].CreatedAt127 })128 return items129}130131func nativeActivityAnswer(content string, options []chatActivityOption) string {132 if content == "" || len(options) == 0 {133 return content134 }135 var answer struct {136 Choices []string `json:"choices"`137 Text string `json:"text"`138 }139 if err := json.Unmarshal([]byte(content), &answer); err != nil || answer.Text == "" {140 answer.Text = ""141 if err := json.Unmarshal([]byte(content), &answer.Choices); err != nil {142 answer.Choices = []string{content}143 }144 }145 labels := make(map[string]string, len(options))146 for _, option := range options {147 labels[option.ID] = option.Label148 }149 selected := make([]string, 0, len(answer.Choices))150 for _, choice := range answer.Choices {151 label, ok := labels[choice]152 if !ok {153 return content154 }155 selected = append(selected, label)156 }157 result := strings.Join(selected, "\n")158 if answer.Text != "" {159 if result != "" {160 result += "\n\n"161 }162 result += answer.Text163 }164 if result == "" {165 return content166 }167 return result168}169170func eventHasTag(item map[string]any, name string) bool {171 for _, tag := range tagValues(item["event"], name) {172 if tag == "1" {173 return true174 }175 }176 return false177}178func truncateActivity(value string, max int) string {179 runes := []rune(value)180 if len(runes) > max {181 return string(runes[:max]) + "…"182 }183 return value184}185func approvalActorMayDecide(item map[string]any, actor string) bool {186 if actor == "" || plainString(item["asker"]) == actor {187 return false188 }189 for _, key := range stringValues(item["asked"]) {190 if key == actor {191 return true192 }193 }194 return false195}196func stringValues(value any) []string {197 raw, err := json.Marshal(value)198 if err != nil {199 return nil200 }201 var values []string202 if json.Unmarshal(raw, &values) != nil {203 return nil204 }205 return values206}207208func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {209 endpoint := "/chat/activity?room=" + url.QueryEscape(room)210 if root != "" {211 endpoint += "&root=" + url.QueryEscape(root)212 }213 page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}214 reader, ok := a.backend.(ChatActivityReader)215 if !ok {216 return page217 }218 result, err := reader.ReadChatActivity(ctx, actor, room, root)219 if err != nil {220 page.Error = "Agent activity is unavailable."221 return page222 }223 data := valueMap(result)224 page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)225 return page226}227func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {228 w.Header().Set("Cache-Control", "private, no-store")229 actor, err := a.resolveActor(r)230 if err != nil {231 http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)232 return233 }234 room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")235 if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {236 http.Error(w, "Invalid room or thread.", http.StatusBadRequest)237 return238 }239 page := a.chatActivity(r.Context(), actor, room, root)240 if page.Error != "" {241 http.Error(w, page.Error, http.StatusForbidden)242 return243 }244 w.Header().Set("Content-Type", "text/html; charset=utf-8")245 var body bytes.Buffer246 if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {247 http.Error(w, "Activity rendering failed: "+err.Error(), 500)248 return249 }250 _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))251}