package server import ( "context" "encoding/json" "fmt" "html" "html/template" "io" "net/http" "os" "os/exec" "regexp" "strings" "time" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/parser" ) // gistResponse represents the relevant fields from GitHub's // Create Gist API response. type gistResponse struct { ID string `json:"id"` HTMLURL string `json:"html_url"` Owner struct { Login string `json:"login"` } `json:"owner"` } var ghAuthTokenOutput = func(ctx context.Context) ([]byte, error) { cmd := exec.CommandContext(ctx, "gh", "auth", "token") cmd.Stderr = io.Discard return cmd.Output() } func githubHTTPClient(timeout time.Duration) *http.Client { transport := http.DefaultTransport if base, ok := http.DefaultTransport.(*http.Transport); ok { transport = base.Clone() } return &http.Client{ Timeout: timeout, Transport: transport, } } func createGist( ctx context.Context, token, filename, description, content string, public bool, ) (*gistResponse, error) { return createGistWithURL( ctx, "https://api.github.com/gists", token, filename, description, content, public, ) } func createGistWithURL( ctx context.Context, apiURL, token, filename, description, content string, public bool, ) (*gistResponse, error) { payload, err := json.Marshal(map[string]any{ "description": description, "public": public, "files": map[string]any{ filename: map[string]string{"content": content}, }, }) if err != nil { return nil, fmt.Errorf("marshaling gist payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(payload))) if err != nil { return nil, fmt.Errorf("creating gist request: %w", err) } req.Header.Set("Authorization", "token "+token) req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "agentsview") client := githubHTTPClient(30 * time.Second) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("github request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, err := io.ReadAll(io.LimitReader(resp.Body, 512)) if err != nil { return nil, fmt.Errorf("github API error: %d: reading body: %w", resp.StatusCode, err) } return nil, fmt.Errorf("github API error: %d: %s", resp.StatusCode, string(body)) } var result gistResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("parsing github response: %w", err) } return &result, nil } func resolveGitHubToken(ctx context.Context, configured string) string { if token := strings.TrimSpace(configured); token != "" { return token } if !isLocalhostContext(ctx) { return "" } if token := strings.TrimSpace(os.Getenv("AGENTSVIEW_GITHUB_TOKEN")); token != "" { return token } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() out, err := ghAuthTokenOutput(cctx) if err != nil { return "" } return strings.TrimSpace(string(out)) } func validateGithubToken(ctx context.Context, token string) (string, error) { return validateGithubTokenWithURL( ctx, "https://api.github.com/user", token, ) } func validateGithubTokenWithURL( ctx context.Context, apiURL, token string, ) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return "", fmt.Errorf("creating validation request: %w", err) } req.Header.Set("Authorization", "token "+token) req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "agentsview") client := githubHTTPClient(10 * time.Second) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("validating token: %w", err) } defer resp.Body.Close() if resp.StatusCode == 401 { return "", fmt.Errorf("invalid GitHub token") } if resp.StatusCode >= 400 { return "", fmt.Errorf("GitHub API error: %d", resp.StatusCode) } var user struct { Login string `json:"login"` } if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return "", fmt.Errorf("parsing user response: %w", err) } return user.Login, nil } type exportData struct { Project string Agent string MessageCount int StartedAt string Messages []exportMessage } type exportMessage struct { Ordinal int RoleClass string ExtraClass string Role string Timestamp string ContentHTML template.HTML FocusedHidden bool } type insightExportData struct { Title string Type string Project string DateRange string Agent string Model string CreatedAt string ContentHTML template.HTML } var exportTmpl = template.Must( template.New("export").Parse(exportTemplateStr)) var insightExportTmpl = template.Must( template.New("insight-export").Parse(insightExportTemplateStr)) const exportTemplateStr = ` {{.Project}} - Agent Session

{{.Project}}

{{.Agent}} {{.MessageCount}} messages {{.StartedAt}}
{{- range .Messages}}
{{.Role}}{{.Timestamp}}
{{.ContentHTML}}
{{- end}}
` const insightExportTemplateStr = ` {{.Title}}

{{.Title}}

{{.Type}} {{.Project}} {{.DateRange}} {{.Agent}} {{if .Model}}{{.Model}}{{end}} {{if .CreatedAt}}{{.CreatedAt}}{{end}}
{{.ContentHTML}}
` func generateExportHTML( session *db.Session, msgs []db.Message, ) string { msgs = filterExportHTMLMessages(msgs) startedAt := "" if session.StartedAt != nil { startedAt = formatTimestamp(*session.StartedAt) } data := exportData{ Project: session.Project, Agent: agentDisplayName(session.Agent), MessageCount: len(msgs), StartedAt: startedAt, Messages: make([]exportMessage, len(msgs)), } focusedVisible := focusedExportOrdinals(msgs) for i, m := range msgs { roleClass := "unknown" if m.Role == "user" || m.Role == "assistant" { roleClass = m.Role } extraClass := "" if m.Role == "assistant" && isThinkingOnly(m.Content) { extraClass = " thinking-only" } data.Messages[i] = exportMessage{ Ordinal: m.Ordinal, RoleClass: roleClass, ExtraClass: extraClass, Role: m.Role, Timestamp: formatTimestamp(m.Timestamp), ContentHTML: template.HTML(formatContentForExport(m.Content)), FocusedHidden: !focusedVisible[m.Ordinal], } } var b strings.Builder if err := exportTmpl.Execute(&b, data); err != nil { return fmt.Sprintf("template error: %s", err) } return b.String() } func filterExportHTMLMessages(msgs []db.Message) []db.Message { filtered := make([]db.Message, 0, len(msgs)) for _, m := range msgs { if db.IsGoalContextPrefixed(m.Content, m.Role) { continue } filtered = append(filtered, m) } return filtered } func generateInsightExportHTML(insight *db.Insight) string { data := insightExportData{ Title: insightExportTitle(insight), Type: insightTypeLabel(insight.Type), Project: insightProjectLabel(insight.Project), DateRange: insightDateRangeLabel(insight.DateFrom, insight.DateTo), Agent: agentDisplayName(insight.Agent), Model: strings.TrimSpace(derefString(insight.Model)), CreatedAt: formatTimestamp(insight.CreatedAt), ContentHTML: template.HTML(formatContentForExport(insight.Content)), } var b strings.Builder if err := insightExportTmpl.Execute(&b, data); err != nil { return fmt.Sprintf("template error: %s", err) } return b.String() } func agentDisplayName(agent string) string { if def, ok := parser.AgentByType(parser.AgentType(agent)); ok { return def.DisplayName } return agent } var ( codeBlockRe = regexp.MustCompile("(?s)```(\\w*)\\n(.*?)```") inlineCodeRe = regexp.MustCompile("`([^`]+)`") thinkingMarkedRe = regexp.MustCompile( `(?s)\[Thinking\]\n?(.*?)\n?\[/Thinking\]`) thinkingLegacyRe = regexp.MustCompile( `(?s)\[Thinking\]\n?(.*?)(?:\n\[|\n\n|$)`) toolBlockRe = regexp.MustCompile( `(?s)\[(` + exportToolNames + `)([^\]]*)\](.*?)(?:\n\[|\n\n|$)`) ) const thinkingHTML = `
` + `
Thinking
$1
` func formatContentForExport(text string) string { s := html.EscapeString(text) s = codeBlockRe.ReplaceAllString(s, "
$2
") s = inlineCodeRe.ReplaceAllString(s, "$1") s = thinkingMarkedRe.ReplaceAllString(s, thinkingHTML) s = thinkingLegacyRe.ReplaceAllString(s, thinkingHTML) s = toolBlockRe.ReplaceAllString(s, `
[$1$2]$3
`) return s } func isThinkingOnly(content string) bool { if content == "" { return false } without := thinkingMarkedRe.ReplaceAllString(content, "") without = thinkingLegacyRe.ReplaceAllString(without, "") return strings.TrimSpace(without) == "" } func focusedExportOrdinals(msgs []db.Message) map[int]bool { visible := make(map[int]bool, len(msgs)) pendingOrdinal := 0 hasPendingAssistant := false toolAfterPendingAssistant := false flushPending := func() { if hasPendingAssistant && !toolAfterPendingAssistant { visible[pendingOrdinal] = true } hasPendingAssistant = false toolAfterPendingAssistant = false } for _, m := range msgs { if m.IsCompactBoundary { flushPending() visible[m.Ordinal] = true continue } if m.IsSystem || db.IsGoalContextPrefixed(m.Content, m.Role) || isThinkingOnly(m.Content) { continue } if isExportToolOnly(m) { if hasPendingAssistant { toolAfterPendingAssistant = true } continue } if m.Role == "user" { flushPending() visible[m.Ordinal] = true continue } // Match the app's focused transcript mode: consecutive // assistant-like messages collapse to the last visible answer. pendingOrdinal = m.Ordinal hasPendingAssistant = true toolAfterPendingAssistant = false } flushPending() return visible } func isExportToolOnly(m db.Message) bool { if m.Role != "assistant" || !m.HasToolUse { return false } for _, segment := range parseMarkdownSegments(m) { switch segment.Type { case markdownSegmentThinking, markdownSegmentTool: continue case markdownSegmentText: if strings.TrimSpace(segment.Content) == "" { continue } return false default: return false } } return true } // parseTimestamp tries RFC3339Nano then RFC3339. func parseTimestamp(ts string) (time.Time, bool) { t, err := time.Parse(time.RFC3339Nano, ts) if err != nil { t, err = time.Parse(time.RFC3339, ts) } return t, err == nil } func formatTimestamp(ts string) string { if ts == "" { return "" } t, ok := parseTimestamp(ts) if !ok { return ts } return t.Format("2006-01-02 15:04:05") } func formatDateShort(ts *string) string { if ts == nil || *ts == "" { return "unknown" } t, ok := parseTimestamp(*ts) if !ok { return "unknown" } return t.Format("20060102") } func sanitizeFilename(name string) string { re := regexp.MustCompile(`[^\w.\-]`) return re.ReplaceAllString(name, "_") } func insightExportStem(insight *db.Insight) string { dateRange := strings.ReplaceAll(insight.DateFrom, "-", "") if insight.DateTo != "" && insight.DateTo != insight.DateFrom { dateRange += "-" + strings.ReplaceAll(insight.DateTo, "-", "") } return sanitizeFilename(fmt.Sprintf( "insight-%s-%s-%s", insight.Type, insightProjectLabel(insight.Project), dateRange, )) } func insightExportHTMLFilename(insight *db.Insight) string { return insightExportStem(insight) + ".html" } func insightExportMarkdownFilename(insight *db.Insight) string { return insightExportStem(insight) + ".md" } func insightExportTitle(insight *db.Insight) string { return fmt.Sprintf( "%s Insight", insightTypeLabel(insight.Type), ) } func insightPublishDescription(insight *db.Insight) string { return fmt.Sprintf( "Insight: %s - %s - %s", insightTypeLabel(insight.Type), insightProjectLabel(insight.Project), insightDateRangeLabel(insight.DateFrom, insight.DateTo), ) } func insightTypeLabel(insightType string) string { switch insightType { case "daily_activity": return "Daily Activity" case "agent_analysis": return "Agent Analysis" default: return strings.ReplaceAll(insightType, "_", " ") } } func insightProjectLabel(project *string) string { value := strings.TrimSpace(derefString(project)) if value == "" { return "global" } return value } func insightDateRangeLabel(dateFrom, dateTo string) string { if dateTo == "" || dateTo == dateFrom { return dateFrom } return dateFrom + " to " + dateTo } func publishExportHTML( ctx context.Context, token, filename, description, htmlContent string, public bool, ) (*publishResponse, error) { gist, err := createGist(ctx, token, filename, description, htmlContent, public) if err != nil { return nil, err } if gist.ID == "" || gist.HTMLURL == "" { return nil, fmt.Errorf("GitHub API returned incomplete gist data") } encoded := urlPathEscape(filename) rawURL := fmt.Sprintf( "https://gist.githubusercontent.com/%s/%s/raw/%s", gist.Owner.Login, gist.ID, encoded, ) return &publishResponse{ GistID: gist.ID, GistURL: gist.HTMLURL, ViewURL: "https://htmlpreview.github.io/?" + rawURL, RawURL: rawURL, }, nil } func derefString(value *string) string { if value == nil { return "" } return *value } func truncateStr(s string, max int) string { if len(s) <= max { return s } // Truncate at a valid rune boundary to avoid producing // invalid UTF-8. r := []rune(s) if len(r) <= max { return s } return string(r[:max]) + "..." }