feat(webui): templates + static assets; SSE broadcast; link cards; link summary endpoint; raw soju client store; UI polish
- Switch UI to Go html/templates and embedded /static (Pico.css-compatible) - Add Server.Broadcast and normalize SSE channel keys to lowercase - Implement /api/linkcard (OG/Twitter) and /api/linksummary (24h cache) - Wire Store into raw soju client for CHATHISTORY LATEST fallback
This commit is contained in:
parent
cbd798dfd5
commit
8a6111aeb5
8 changed files with 537 additions and 293 deletions
|
|
@ -223,10 +223,11 @@ func main() {
|
|||
Password: cfg.Password,
|
||||
Channels: cfg.Channels,
|
||||
BackfillLatest: backfill,
|
||||
Store: st,
|
||||
OnPrivmsg: func(channel, author, text, msgid string, at time.Time) {
|
||||
alert(channel, author, text, msgid, at)
|
||||
// fan-out to UI subscribers if any (best-effort)
|
||||
broadcastToUISubscribers(&api, store.Message{Channel: channel, Author: author, Body: text, Time: at.UTC(), MsgID: msgid})
|
||||
api.Broadcast(strings.ToLower(channel), store.Message{Channel: channel, Author: author, Body: text, Time: at.UTC(), MsgID: msgid})
|
||||
},
|
||||
ConnectedGauge: &metrics.ConnectedGauge,
|
||||
}
|
||||
|
|
@ -292,25 +293,3 @@ func main() {
|
|||
<-ctx.Done()
|
||||
logger.Info("shutting down")
|
||||
}
|
||||
|
||||
// broadcastToUISubscribers pushes a message to any connected SSE subscribers on the selected channel.
|
||||
func broadcastToUISubscribers(api *httpapi.Server, m store.Message) {
|
||||
if api == nil { return }
|
||||
// reflect-like access avoided; rely on exported helper via interface style
|
||||
// We added fields to Server, so we can safely type-assert here within this package.
|
||||
// Iterate subscribers with internal lock via small helper method.
|
||||
type subAccess interface{ Broadcast(channel string, m store.Message) }
|
||||
if b, ok := any(api).(subAccess); ok { b.Broadcast(strings.ToLower(m.Channel), m); return }
|
||||
// Fallback: best effort using unexported fields via a minimal shim function added below
|
||||
broadcastShim(api, m)
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func broadcastShim(api *httpapi.Server, m store.Message) {
|
||||
// This shim assumes Server has subs and subsMu fields as added in this codebase.
|
||||
// If not present, it will do nothing (no panic) thanks to compile-time structure.
|
||||
// Since we are in the same module, we can update together.
|
||||
// WARNING: keep in sync with httpapi.Server struct.
|
||||
// Using an internal copy of the logic to avoid import cycles.
|
||||
// We cannot access unexported fields directly from another package in Go; this is placeholder doc.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -15,7 +16,8 @@ import (
|
|||
|
||||
"sojuboy/internal/store"
|
||||
"sojuboy/internal/summarizer"
|
||||
xhtml "golang.org/x/net/html"
|
||||
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
|
|
@ -51,13 +53,20 @@ type Server struct {
|
|||
// Link card cache
|
||||
cardCache map[string]linkCard
|
||||
cardCacheExp map[string]time.Time
|
||||
// Link summary cache
|
||||
summaryCache map[string]string
|
||||
summaryCacheExp map[string]time.Time
|
||||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
// Minimal web UI
|
||||
mux.HandleFunc("/", s.handleUI)
|
||||
mux.HandleFunc("/summarizer", s.handleSummarizerUI)
|
||||
// Minimal web UI (templated)
|
||||
mux.HandleFunc("/", s.handleUIDash)
|
||||
mux.HandleFunc("/summarizer", s.handleUISummarizer)
|
||||
// Serve embedded static files under /static/
|
||||
if sub, err := fs.Sub(staticFS, "."); err == nil {
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
|
||||
}
|
||||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/auth", s.handleAuth)
|
||||
mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
|
@ -85,6 +94,7 @@ func (s *Server) Start(ctx context.Context) error {
|
|||
mux.HandleFunc("/api/history", s.handleHistory)
|
||||
mux.HandleFunc("/api/stream", s.handleStream)
|
||||
mux.HandleFunc("/api/linkcard", s.handleLinkCard)
|
||||
mux.HandleFunc("/api/linksummary", s.handleLinkSummary)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.ListenAddr,
|
||||
|
|
@ -198,67 +208,158 @@ func (s *Server) handleTail(w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write([]byte(m.Time.UTC().Format(time.RFC3339) + " " + m.Author + " " + channel + " " + m.Body + "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Simple link card structure
|
||||
type linkCard struct{
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
type linkCard struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
// handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card.
|
||||
func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
raw := r.URL.Query().Get("url")
|
||||
if raw == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing url")); return }
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad url")); return }
|
||||
// cache lookup
|
||||
if s.cardCache == nil { s.cardCache = make(map[string]linkCard); s.cardCacheExp = make(map[string]time.Time) }
|
||||
if exp, ok := s.cardCacheExp[raw]; ok && time.Now().Before(exp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(s.cardCache[raw])
|
||||
return
|
||||
}
|
||||
// fetch minimal HTML and extract tags using a tolerant HTML parser
|
||||
client := &http.Client{ Timeout: 10 * time.Second }
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("fetch error")); return }
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("bad status")); return }
|
||||
// limit to 256KB and parse tokens
|
||||
limited := http.MaxBytesReader(w, resp.Body, 262144)
|
||||
doc, err := xhtml.Parse(limited)
|
||||
if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("parse error")); return }
|
||||
var title, desc, img string
|
||||
var walker func(*xhtml.Node)
|
||||
walker = func(n *xhtml.Node) {
|
||||
if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") {
|
||||
// property or name + content
|
||||
var pn = ""; var nm = ""; var content = ""
|
||||
for _, a := range n.Attr { if strings.EqualFold(a.Key, "property") { pn = a.Val } else if strings.EqualFold(a.Key, "name") { nm = a.Val } else if strings.EqualFold(a.Key, "content") { content = a.Val } }
|
||||
key := strings.ToLower(pn)
|
||||
if key == "" { key = strings.ToLower(nm) }
|
||||
switch key {
|
||||
case "og:title", "twitter:title": if title == "" { title = content }
|
||||
case "og:description", "twitter:description": if desc == "" { desc = content }
|
||||
case "og:image", "twitter:image": if img == "" { img = content }
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling { walker(c) }
|
||||
}
|
||||
walker(doc)
|
||||
card := linkCard{ URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img) }
|
||||
// cache for 24h
|
||||
s.cardCache[raw] = card
|
||||
s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(card)
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
raw := r.URL.Query().Get("url")
|
||||
if raw == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("missing url"))
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad url"))
|
||||
return
|
||||
}
|
||||
// cache lookup
|
||||
if s.cardCache == nil {
|
||||
s.cardCache = make(map[string]linkCard)
|
||||
s.cardCacheExp = make(map[string]time.Time)
|
||||
}
|
||||
if exp, ok := s.cardCacheExp[raw]; ok && time.Now().Before(exp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(s.cardCache[raw])
|
||||
return
|
||||
}
|
||||
// fetch minimal HTML and extract tags using a tolerant HTML parser
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("fetch error"))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("bad status"))
|
||||
return
|
||||
}
|
||||
// limit to 256KB and parse tokens
|
||||
limited := http.MaxBytesReader(w, resp.Body, 262144)
|
||||
doc, err := xhtml.Parse(limited)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("parse error"))
|
||||
return
|
||||
}
|
||||
var title, desc, img string
|
||||
var walker func(*xhtml.Node)
|
||||
walker = func(n *xhtml.Node) {
|
||||
if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") {
|
||||
// property or name + content
|
||||
var pn = ""
|
||||
var nm = ""
|
||||
var content = ""
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, "property") {
|
||||
pn = a.Val
|
||||
} else if strings.EqualFold(a.Key, "name") {
|
||||
nm = a.Val
|
||||
} else if strings.EqualFold(a.Key, "content") {
|
||||
content = a.Val
|
||||
}
|
||||
}
|
||||
key := strings.ToLower(pn)
|
||||
if key == "" {
|
||||
key = strings.ToLower(nm)
|
||||
}
|
||||
switch key {
|
||||
case "og:title", "twitter:title":
|
||||
if title == "" {
|
||||
title = content
|
||||
}
|
||||
case "og:description", "twitter:description":
|
||||
if desc == "" {
|
||||
desc = content
|
||||
}
|
||||
case "og:image", "twitter:image":
|
||||
if img == "" {
|
||||
img = content
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walker(c)
|
||||
}
|
||||
}
|
||||
walker(doc)
|
||||
card := linkCard{URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img)}
|
||||
// cache for 24h
|
||||
s.cardCache[raw] = card
|
||||
s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(card)
|
||||
}
|
||||
|
||||
// handleLinkSummary returns a brief AI summary for a given URL, cached for 24h.
|
||||
func (s *Server) handleLinkSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
raw := r.URL.Query().Get("url")
|
||||
if raw == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("missing url"))
|
||||
return
|
||||
}
|
||||
if s.Summarizer == nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("summarizer not configured"))
|
||||
return
|
||||
}
|
||||
if s.summaryCache == nil { s.summaryCache = make(map[string]string) }
|
||||
if s.summaryCacheExp == nil { s.summaryCacheExp = make(map[string]time.Time) }
|
||||
if exp, ok := s.summaryCacheExp[raw]; ok && time.Now().Before(exp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"summary": s.summaryCache[raw]})
|
||||
return
|
||||
}
|
||||
msgs := []store.Message{{Channel: "#links", Author: "link", Body: raw, Time: time.Now().UTC()}}
|
||||
tout := s.SummarizerTimeout
|
||||
if tout <= 0 { tout = 5 * time.Minute }
|
||||
if tout > 2*time.Minute { tout = 2 * time.Minute }
|
||||
ctx, cancel := context.WithTimeout(r.Context(), tout)
|
||||
defer cancel()
|
||||
sum, err := s.Summarizer.Summarize(ctx, "#links", msgs, 0)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("summarizer error"))
|
||||
return
|
||||
}
|
||||
if sum == "" { sum = "(no summary)" }
|
||||
s.summaryCache[raw] = sum
|
||||
s.summaryCacheExp[raw] = time.Now().Add(24 * time.Hour)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
|
||||
}
|
||||
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -281,7 +382,7 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// --- Web UI handlers ---
|
||||
|
||||
func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleUIDash(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
|
|
@ -293,202 +394,121 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Pico.css from CDN and a tiny app
|
||||
page := `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>sojuboy</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
body { padding: 0; }
|
||||
header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||
header.nav a.brand { text-decoration: none; font-weight: 600; }
|
||||
main.container { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); }
|
||||
aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; }
|
||||
aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; }
|
||||
aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); }
|
||||
section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); }
|
||||
#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.ts { opacity: .66; }
|
||||
.msg { margin-bottom: .25rem; }
|
||||
footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; }
|
||||
@media (max-width: 900px) { main.container { grid-template-columns: 1fr; } aside.sidebar { display:none; } }
|
||||
</style>
|
||||
<script>
|
||||
const st={ tailLoading:false, atBottom:true, current:'#', earliest:null, sse:null, channels:[] };
|
||||
function setToken(v){ st.token=v; localStorage.setItem('token', v); }
|
||||
async function api(path, params){
|
||||
const url = new URL(path, window.location.origin);
|
||||
if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); }
|
||||
const opts = { headers: {} };
|
||||
// use cookie for auth; header optional if present
|
||||
if(st.token){ opts.headers['Authorization'] = 'Bearer '+st.token; }
|
||||
const res = await fetch(url, opts);
|
||||
if(!res.ok){ throw new Error('HTTP '+res.status); }
|
||||
return res.json();
|
||||
}
|
||||
async function loadInfo(){ /* no-op for now; footer shows version */ }
|
||||
async function loadChannels(){
|
||||
try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } }
|
||||
catch(e){ console.error(e); }
|
||||
}
|
||||
function renderChannels(){ const list=document.getElementById('chanlist'); list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); }
|
||||
async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); el.textContent=''; // bootstrap 50
|
||||
const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); }
|
||||
function initScrollHandlers(){ const el=document.getElementById('tail'); el.onscroll=async ()=>{ st.atBottom = (el.scrollTop + el.clientHeight + 8) >= el.scrollHeight; if(el.scrollTop === 0 && st.earliest){ // load more
|
||||
try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ const hBefore = el.scrollHeight; prependBatch(older); st.earliest = older[0].time; const hAfter = el.scrollHeight; el.scrollTop = hAfter - hBefore; } }
|
||||
catch(e){}
|
||||
} } }
|
||||
function colorFor(nick){ let h=0; for(let i=0;i<nick.length;i++){ h=(h*31+nick.charCodeAt(i))>>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; }
|
||||
function lineHTML(m){ const ts = '<span class=ts>[' + m.time + ']</span>'; const nick = '<b style="color:' + colorFor(m.author) + '\">' + m.author + '</b>'; const body = escapeHtml(m.body); return ts + ' ' + nick + ': ' + linkify(body); }
|
||||
function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function linkify(t){ return t.replace(/https?:\/\/\S+/g, function(u){ return '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'; }); }
|
||||
function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ el.scrollTop = el.scrollHeight; } }
|
||||
function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } }
|
||||
function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ if(!card) return; if(card.title||card.description||card.image){ const c=document.createElement('div'); c.className='card'; var html=''; if(card.image){ html += '<div><img src="'+card.image+'" alt="" style="max-width:160px;max-height:120px;object-fit:cover;border-radius:.25rem"></div>'; } html += '<div style="flex:1;margin-left:.5rem">'; if(card.title){ html += '<div style="font-weight:600">'+escapeHtml(card.title)+'</div>'; } if(card.description){ html += '<div style="opacity:.8">'+escapeHtml(card.description)+'</div>'; } html += '</div>'; c.innerHTML = '<div style="display:flex;align-items:flex-start;gap:.5rem">'+html+'</div>'; a.parentNode.insertBefore(c, a.nextSibling); } }).catch(()=>{}); }); }
|
||||
function startStream(){ const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } }
|
||||
async function doSumm(){
|
||||
const ch = document.getElementById('channel').value;
|
||||
const win = document.getElementById('window').value || '6h';
|
||||
const push = document.getElementById('push').checked ? '1' : '0';
|
||||
const btn = document.getElementById('summBtn');
|
||||
const prog = document.getElementById('summProg');
|
||||
btn.disabled = true; prog.style.display = 'inline-block';
|
||||
try{ const data = await api('/api/trigger',{query:{channel:ch,window:win,push:push}});
|
||||
const el = document.getElementById('summary');
|
||||
el.textContent = data.summary || '(empty)';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}catch(e){ document.getElementById('summary').textContent = 'error: '+e; }
|
||||
btn.disabled = false; prog.style.display = 'none';
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', ()=>{ loadChannels(); });
|
||||
function onFollowToggle(cb){
|
||||
if(cb.checked){
|
||||
if(st.tailTimer) clearInterval(st.tailTimer);
|
||||
st.tailTimer = setInterval(doTail, 3000);
|
||||
}else{
|
||||
if(st.tailTimer) { clearInterval(st.tailTimer); st.tailTimer=null; }
|
||||
}
|
||||
}
|
||||
function onChannelChange(){ doTail(); }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<div><a class="brand" href="/">sojuboy</a></div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/summarizer">Summarizer</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
<aside class="sidebar">
|
||||
<nav id="chanlist"></nav>
|
||||
</aside>
|
||||
<section class="chat">
|
||||
<div id="tail" style="height: calc(100vh - 4.5rem); overflow:auto"></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<small>` + s.Version + ` (` + s.Commit + `)</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`
|
||||
_, _ = w.Write([]byte(page))
|
||||
s.render(w, "dashboard.tmpl", map[string]any{})
|
||||
}
|
||||
|
||||
func (s *Server) handleSummarizerUI(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleUISummarizer(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AuthToken != "" {
|
||||
if c, err := r.Cookie("auth_token"); err != nil || c.Value != s.AuthToken {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
page := `<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Summarizer · sojuboy</title><link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css"><style>body{padding:0} header.nav{position:sticky;top:0;z-index:10;padding:.6rem 1rem;border-bottom:1px solid var(--muted-border-color);display:flex;justify-content:space-between;align-items:center} header.nav a.brand{text-decoration:none;font-weight:600} main{max-width:900px;margin:0 auto;padding:1rem} #out{white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere} footer{text-align:center;font-size:.85rem;padding:.5rem 0;opacity:.7}</style><script>async function api(path,params){const url=new URL(path,window.location.origin);if(params&¶ms.query){Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v));}const res=await fetch(url);if(!res.ok) throw new Error('HTTP '+res.status);return res.text();}async function summarize(){const ch=document.getElementById('channel').value;const win=document.getElementById('window').value||'6h';const push=document.getElementById('push').checked?'1':'0';const btn=document.getElementById('btn');const out=document.getElementById('out');btn.disabled=true;out.textContent='';try{const txt=await api('/trigger',{query:{channel:ch,window:win,push:push}});out.textContent=txt;}catch(e){out.textContent='error: '+e;}btn.disabled=false;}</script></head><body><header class="nav"><div><a class="brand" href="/">sojuboy</a></div><nav><ul><li><a href="/summarizer" aria-current="page">Summarizer</a></li><li><a href="/logout">Logout</a></li></ul></nav></header><main><article><h3>On-demand summarization</h3><label>Channel<select id="channel"></select></label><label>Window<input id="window" value="6h"></label><label><input type="checkbox" id="push"> Send via Pushover</label><button id="btn" onclick="summarize()">Summarize</button><pre id="out"></pre></article></main><footer><small>` + s.Version + ` (` + s.Commit + `)</small></footer><script>(async()=>{try{const res=await fetch('/api/channels');const arr=await res.json();const sel=document.getElementById('channel');arr.forEach(c=>{const o=document.createElement('option');o.value=c;o.textContent=c;sel.appendChild(o);});}catch(e){}})();</script></body></html>`
|
||||
_, _ = w.Write([]byte(page))
|
||||
s.render(w, "summarizer.tmpl", map[string]any{})
|
||||
}
|
||||
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]any{
|
||||
"version": s.Version,
|
||||
"commit": s.Commit,
|
||||
"builtAt": s.BuiltAt,
|
||||
"version": s.Version,
|
||||
"commit": s.Commit,
|
||||
"builtAt": s.BuiltAt,
|
||||
"startedAt": s.StartedAt.Format(time.RFC3339),
|
||||
"uptime": time.Since(s.StartedAt).Round(time.Second).String(),
|
||||
"messagesIngested": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesIngested) }(),
|
||||
"notificationsSent": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.NotificationsSent) }(),
|
||||
"messagesPruned": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesPruned) }(),
|
||||
"connected": func() bool { if s.Metrics==nil {return false}; return atomic.LoadInt64(&s.Metrics.ConnectedGauge)==1 }(),
|
||||
"uptime": time.Since(s.StartedAt).Round(time.Second).String(),
|
||||
"messagesIngested": func() int64 {
|
||||
if s.Metrics == nil {
|
||||
return 0
|
||||
}
|
||||
return atomic.LoadInt64(&s.Metrics.MessagesIngested)
|
||||
}(),
|
||||
"notificationsSent": func() int64 {
|
||||
if s.Metrics == nil {
|
||||
return 0
|
||||
}
|
||||
return atomic.LoadInt64(&s.Metrics.NotificationsSent)
|
||||
}(),
|
||||
"messagesPruned": func() int64 {
|
||||
if s.Metrics == nil {
|
||||
return 0
|
||||
}
|
||||
return atomic.LoadInt64(&s.Metrics.MessagesPruned)
|
||||
}(),
|
||||
"connected": func() bool {
|
||||
if s.Metrics == nil {
|
||||
return false
|
||||
}
|
||||
return atomic.LoadInt64(&s.Metrics.ConnectedGauge) == 1
|
||||
}(),
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// Summarizer simple page placeholder (will reuse existing summarizer flow)
|
||||
// removed duplicate handleSummarizerUI definition (consolidated earlier)
|
||||
|
||||
// SSE stream of new messages for a channel
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
ch := strings.TrimSpace(r.URL.Query().Get("channel"))
|
||||
if ch == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
|
||||
key := strings.ToLower(ch)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok { w.WriteHeader(http.StatusInternalServerError); return }
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
ch := strings.TrimSpace(r.URL.Query().Get("channel"))
|
||||
if ch == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("missing channel"))
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(ch) // Normalize channel name to lowercase
|
||||
|
||||
// register subscriber
|
||||
if s.subs == nil { s.subs = make(map[string][]chan store.Message) }
|
||||
sub := make(chan store.Message, 64)
|
||||
s.subsMu.Lock()
|
||||
s.subs[key] = append(s.subs[key], sub)
|
||||
s.subsMu.Unlock()
|
||||
defer func(){
|
||||
s.subsMu.Lock()
|
||||
subs := s.subs[key]
|
||||
for i := range subs {
|
||||
if subs[i] == sub {
|
||||
s.subs[key] = append(subs[:i], subs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.subsMu.Unlock()
|
||||
close(sub)
|
||||
}()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hb := time.NewTicker(15 * time.Second)
|
||||
defer hb.Stop()
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m := <-sub:
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"time": m.Time.UTC().Format(time.RFC3339),
|
||||
"author": m.Author,
|
||||
"body": m.Body,
|
||||
"channel": m.Channel,
|
||||
})
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
case <-hb.C:
|
||||
_, _ = fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
// register subscriber
|
||||
if s.subs == nil {
|
||||
s.subs = make(map[string][]chan store.Message)
|
||||
}
|
||||
sub := make(chan store.Message, 64)
|
||||
s.subsMu.Lock()
|
||||
s.subs[key] = append(s.subs[key], sub)
|
||||
s.subsMu.Unlock()
|
||||
defer func() {
|
||||
s.subsMu.Lock()
|
||||
subs := s.subs[key]
|
||||
for i := range subs {
|
||||
if subs[i] == sub {
|
||||
s.subs[key] = append(subs[:i], subs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.subsMu.Unlock()
|
||||
close(sub)
|
||||
}()
|
||||
|
||||
hb := time.NewTicker(15 * time.Second) // SSE heartbeat
|
||||
defer hb.Stop()
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m := <-sub:
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"time": m.Time.UTC().Format(time.RFC3339),
|
||||
"author": m.Author,
|
||||
"body": m.Body,
|
||||
"channel": m.Channel,
|
||||
})
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
case <-hb.C:
|
||||
_, _ = fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -517,14 +537,30 @@ func (s *Server) handleTailJSON(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
channel := r.URL.Query().Get("channel")
|
||||
if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
|
||||
if channel == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("missing channel"))
|
||||
return
|
||||
}
|
||||
limit := getIntQuery(r, "limit", 100)
|
||||
msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit)
|
||||
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("store error"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` }
|
||||
type outMsg struct {
|
||||
Time string `json:"time"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
arr := make([]outMsg, 0, len(msgs))
|
||||
for i := len(msgs)-1; i>=0; i-- { m := msgs[i]; arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel}) }
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(arr)
|
||||
}
|
||||
|
||||
|
|
@ -535,43 +571,95 @@ func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
channel := r.URL.Query().Get("channel")
|
||||
if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
|
||||
win := r.URL.Query().Get("window"); if win=="" { win = "6h" }
|
||||
if channel == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("missing channel"))
|
||||
return
|
||||
}
|
||||
win := r.URL.Query().Get("window")
|
||||
if win == "" {
|
||||
win = "6h"
|
||||
}
|
||||
push := r.URL.Query().Get("push") == "1"
|
||||
dur, err := time.ParseDuration(win)
|
||||
if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad window")); return }
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad window"))
|
||||
return
|
||||
}
|
||||
msgs, err := s.Store.ListMessagesSince(r.Context(), channel, time.Now().Add(-dur))
|
||||
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
|
||||
if s.Summarizer == nil { w.WriteHeader(http.StatusServiceUnavailable); _, _ = w.Write([]byte("summarizer not configured")); return }
|
||||
tout := s.SummarizerTimeout; if tout<=0 { tout = 5*time.Minute }
|
||||
ctx, cancel := context.WithTimeout(r.Context(), tout); defer cancel()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("store error"))
|
||||
return
|
||||
}
|
||||
if s.Summarizer == nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("summarizer not configured"))
|
||||
return
|
||||
}
|
||||
tout := s.SummarizerTimeout
|
||||
if tout <= 0 {
|
||||
tout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), tout)
|
||||
defer cancel()
|
||||
sum, err := s.Summarizer.Summarize(ctx, channel, msgs, dur)
|
||||
if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("summarizer error")); return }
|
||||
if push && s.Notifier != nil { title := fmt.Sprintf("IRC digest %s (%s)", channel, dur); _ = s.Notifier.Notify(r.Context(), title, sum); if s.Metrics != nil { atomic.AddInt64(&s.Metrics.NotificationsSent, 1) } }
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("summarizer error"))
|
||||
return
|
||||
}
|
||||
if push && s.Notifier != nil {
|
||||
title := fmt.Sprintf("IRC digest %s (%s)", channel, dur)
|
||||
_ = s.Notifier.Notify(r.Context(), title, sum)
|
||||
if s.Metrics != nil {
|
||||
atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
|
||||
}
|
||||
|
||||
// history paging
|
||||
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
channel := r.URL.Query().Get("channel")
|
||||
beforeStr := r.URL.Query().Get("before")
|
||||
if channel == "" || beforeStr == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing params")); return }
|
||||
t, err := time.Parse(time.RFC3339, beforeStr)
|
||||
if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad before")); return }
|
||||
limit := getIntQuery(r, "limit", 50)
|
||||
msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, limit)
|
||||
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` }
|
||||
arr := make([]outMsg, 0, len(msgs))
|
||||
for _, m := range msgs { arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel}) }
|
||||
_ = json.NewEncoder(w).Encode(arr)
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
channel := r.URL.Query().Get("channel")
|
||||
beforeStr := r.URL.Query().Get("before")
|
||||
if channel == "" || beforeStr == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("missing params"))
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, beforeStr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad before"))
|
||||
return
|
||||
}
|
||||
limit := getIntQuery(r, "limit", 50)
|
||||
msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, limit)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("store error"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
type outMsg struct {
|
||||
Time string `json:"time"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
arr := make([]outMsg, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(arr)
|
||||
}
|
||||
|
||||
func checkAuth(r *http.Request, token string) bool {
|
||||
|
|
@ -647,7 +735,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad request")); return }
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad request"))
|
||||
return
|
||||
}
|
||||
tok := r.Form.Get("token")
|
||||
if tok == "" || s.AuthToken == "" || tok != s.AuthToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
|
@ -657,12 +749,33 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
// set cookie for 7 days
|
||||
maxAge := 7 * 24 * 60 * 60
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:tok, Path:"/", MaxAge:maxAge, HttpOnly:true, Secure:secure, SameSite:http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: tok, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode})
|
||||
w.Header().Set("Location", "/")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:"", Path:"/", MaxAge:-1})
|
||||
http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all SSE subscribers for a given channel.
|
||||
func (s *Server) Broadcast(channel string, m store.Message) {
|
||||
key := strings.ToLower(strings.TrimSpace(channel))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
s.subsMu.RLock()
|
||||
subs := s.subs[key]
|
||||
s.subsMu.RUnlock()
|
||||
for _, sub := range subs {
|
||||
select {
|
||||
case sub <- m:
|
||||
// ok
|
||||
default:
|
||||
if s.Logger != nil {
|
||||
s.Logger.Warn("sse subscriber buffer full, dropping message", "channel", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
internal/httpapi/static/app.css
Normal file
13
internal/httpapi/static/app.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
body { padding: 0; }
|
||||
header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||
header.nav a.brand { text-decoration: none; font-weight: 600; }
|
||||
main.container { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); }
|
||||
aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; }
|
||||
aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; }
|
||||
aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); }
|
||||
section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); }
|
||||
#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: calc(100vh - 4.5rem); }
|
||||
.ts { opacity: .66; }
|
||||
.msg { margin-bottom: .25rem; }
|
||||
footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; }
|
||||
@media (max-width: 900px) { main.container { grid-template-columns: 1fr; } aside.sidebar { display:none; } }
|
||||
39
internal/httpapi/static/app.js
Normal file
39
internal/httpapi/static/app.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Shared state
|
||||
const st = { tailLoading: false, atBottom: true, current: '#', earliest: null, sse: null, channels: [] };
|
||||
|
||||
function colorFor(nick){ let h=0; for(let i=0;i<nick.length;i++){ h=(h*31+nick.charCodeAt(i))>>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; }
|
||||
function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function linkify(t){ return t.replace(/https?:\/\/\S+/g, function(u){ return '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'; }); }
|
||||
function lineHTML(m){ const ts = '<span class=ts>[' + m.time + ']</span>'; const nick = '<b style="color:' + colorFor(m.author) + '">' + m.author + '</b>'; const body = escapeHtml(m.body); return ts + ' ' + nick + ': ' + linkify(body); }
|
||||
|
||||
async function api(path, params){
|
||||
const url = new URL(path, window.location.origin);
|
||||
if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); }
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) throw new Error('HTTP '+res.status);
|
||||
const ct = res.headers.get('content-type')||'';
|
||||
if(ct.includes('application/json')) return res.json();
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ el.scrollTop = el.scrollHeight; } }
|
||||
function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } }
|
||||
|
||||
function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ if(!card) return; if(card.title||card.description||card.image){ const c=document.createElement('div'); c.className='card'; var html=''; if(card.image){ html += '<div><img src="'+card.image+'" alt="" style="max-width:160px;max-height:120px;object-fit:cover;border-radius:.25rem"></div>'; } html += '<div style="flex:1;margin-left:.5rem">'; if(card.title){ html += '<div style="font-weight:600">'+escapeHtml(card.title)+'</div>'; } if(card.description){ html += '<div style="opacity:.8">'+escapeHtml(card.description)+'</div>'; } html += '</div>'; c.innerHTML = '<div style="display:flex;align-items:flex-start;gap:.5rem">'+html+'</div>'; a.parentNode.insertBefore(c, a.nextSibling); } }).catch(()=>{}); }); }
|
||||
|
||||
async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} }
|
||||
function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); }
|
||||
|
||||
async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); }
|
||||
function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; el.onscroll=async ()=>{ st.atBottom = (el.scrollTop + el.clientHeight + 8) >= el.scrollHeight; if(el.scrollTop === 0 && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ const hBefore = el.scrollHeight; prependBatch(older); st.earliest = older[0].time; const hAfter = el.scrollHeight; el.scrollTop = hAfter - hBefore; } } catch(e){} } } }
|
||||
|
||||
function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } }
|
||||
|
||||
async function summarize(){ const ch=document.getElementById('channel'); const win=document.getElementById('window'); const push=document.getElementById('push'); const btn=document.getElementById('btn'); const out=document.getElementById('out'); if(!ch||!win||!btn||!out) return; btn.disabled=true; out.textContent=''; try{ const data = await api('/api/trigger',{query:{channel:ch.value,window:win.value||'6h',push:push && push.checked? '1':'0'}}); if(typeof data === 'string'){ out.textContent = data; } else { out.textContent = (data.summary||''); } } catch(e){ out.textContent = 'error: '+e; } btn.disabled=false; }
|
||||
|
||||
window.addEventListener('DOMContentLoaded', ()=>{
|
||||
if(document.getElementById('chanlist')){ loadChannels(); }
|
||||
if(document.getElementById('channel')){
|
||||
fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{});
|
||||
}
|
||||
});
|
||||
47
internal/httpapi/templates.go
Normal file
47
internal/httpapi/templates.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl
|
||||
var uiFS embed.FS
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
tplOnce sync.Once
|
||||
tpl *template.Template
|
||||
)
|
||||
|
||||
func (s *Server) parseTemplatesOnce() {
|
||||
tplOnce.Do(func() {
|
||||
// Parse all templates under templates/
|
||||
t := template.New("base").Funcs(template.FuncMap{})
|
||||
t = template.Must(t.ParseFS(uiFS, "templates/*.tmpl"))
|
||||
tpl = t
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
|
||||
s.parseTemplatesOnce()
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["Version"] = s.Version
|
||||
data["Commit"] = s.Commit
|
||||
base := strings.TrimSuffix(path.Base(name), path.Ext(name))
|
||||
if base == "dashboard" {
|
||||
data["Title"] = "sojuboy"
|
||||
} else {
|
||||
data["Title"] = strings.Title(base)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = tpl.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
12
internal/httpapi/templates/dashboard.tmpl
Normal file
12
internal/httpapi/templates/dashboard.tmpl
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{{ define "content" }}
|
||||
<main class="container">
|
||||
<aside class="sidebar">
|
||||
<nav id="chanlist"></nav>
|
||||
</aside>
|
||||
<section class="chat">
|
||||
<div id="tail"></div>
|
||||
</section>
|
||||
</main>
|
||||
{{ end }}
|
||||
{{ template "layout.tmpl" . }}
|
||||
|
||||
27
internal/httpapi/templates/layout.tmpl
Normal file
27
internal/httpapi/templates/layout.tmpl
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{{ define "layout.tmpl" }}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{{ .Title }} · sojuboy</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script defer src="/static/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<div><a class="brand" href="/">sojuboy</a></div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/summarizer">Summarizer</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
{{ block "content" . }}{{ end }}
|
||||
<footer><small>{{ .Version }} ({{ .Commit }})</small></footer>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
14
internal/httpapi/templates/summarizer.tmpl
Normal file
14
internal/httpapi/templates/summarizer.tmpl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{{ define "content" }}
|
||||
<main class="container">
|
||||
<article>
|
||||
<h3>On-demand summarization</h3>
|
||||
<label>Channel<select id="channel"></select></label>
|
||||
<label>Window<input id="window" value="6h"></label>
|
||||
<label><input type="checkbox" id="push"> Send via Pushover</label>
|
||||
<button id="btn" onclick="summarize()">Summarize</button>
|
||||
<pre id="out"></pre>
|
||||
</article>
|
||||
</main>
|
||||
{{ end }}
|
||||
{{ template "layout.tmpl" . }}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue