mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-11 02:09:07 +00:00
feat: 增加viewer能力
This commit is contained in:
parent
576180d32b
commit
d99e3eb969
9 changed files with 1045 additions and 0 deletions
|
|
@ -49,6 +49,8 @@ func dispatch() error {
|
|||
return runConfig(args[1:])
|
||||
case "llm":
|
||||
return runLLM(args[1:])
|
||||
case "viewer":
|
||||
return runViewer(args[1:])
|
||||
case "-h", "--help":
|
||||
printTopLevelUsage()
|
||||
return nil
|
||||
|
|
@ -67,6 +69,7 @@ Commands:
|
|||
review, r Start a code review
|
||||
config Manage configuration settings
|
||||
llm LLM utility commands
|
||||
viewer Start the WebUI session viewer
|
||||
version Show version information
|
||||
|
||||
Examples:
|
||||
|
|
|
|||
55
cmd/opencodereview/viewer_cmd.go
Normal file
55
cmd/opencodereview/viewer_cmd.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/viewer"
|
||||
)
|
||||
|
||||
type viewerOptions struct {
|
||||
addr string
|
||||
showHelp bool
|
||||
}
|
||||
|
||||
func parseViewerFlags(args []string) (viewerOptions, error) {
|
||||
a := newOcrFlagSet("ocr viewer")
|
||||
|
||||
opts := viewerOptions{}
|
||||
a.StringVar(&opts.addr, "addr", "localhost:5483", "listen address")
|
||||
|
||||
if err := a.Parse(args); err != nil {
|
||||
return opts, fmt.Errorf("parse flags: %w", err)
|
||||
}
|
||||
|
||||
opts.showHelp = a.showHelp
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func runViewer(args []string) error {
|
||||
opts, err := parseViewerFlags(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.showHelp {
|
||||
printViewerUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Open Code Review Viewer starting on http://%s\n", opts.addr)
|
||||
return viewer.StartServer(opts.addr)
|
||||
}
|
||||
|
||||
func printViewerUsage() {
|
||||
fmt.Println(`Session history WebUI viewer.
|
||||
|
||||
Usage:
|
||||
ocr viewer [flags]
|
||||
ocr v [flags] (alias)
|
||||
|
||||
Flags:
|
||||
--addr <address> listen address (default: localhost:5483)
|
||||
|
||||
Examples:
|
||||
ocr viewer # start on default port
|
||||
ocr viewer --addr :3000 # bind to all interfaces on port 3000`)
|
||||
}
|
||||
79
internal/viewer/handler.go
Normal file
79
internal/viewer/handler.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package viewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func handleRepos(w http.ResponseWriter, r *http.Request, root string) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
repos, err := DiscoverRepos(root)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
renderTemplate(w, "repos.html", map[string]any{
|
||||
"Repos": repos,
|
||||
})
|
||||
}
|
||||
|
||||
type sessionsData struct {
|
||||
EncodedRepo string
|
||||
RepoName string
|
||||
Sessions []SessionSummary
|
||||
}
|
||||
|
||||
func handleSessions(w http.ResponseWriter, r *http.Request, root, repo string) {
|
||||
summaries, err := ListSessions(root, repo)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Derive a display name from the first session's CWD
|
||||
name := repo
|
||||
for _, s := range summaries {
|
||||
if s.CWD != "" {
|
||||
name = filepath.Base(s.CWD)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
renderTemplate(w, "sessions.html", sessionsData{
|
||||
EncodedRepo: repo,
|
||||
RepoName: name,
|
||||
Sessions: summaries,
|
||||
})
|
||||
}
|
||||
|
||||
type sessionPageData struct {
|
||||
EncodedRepo string
|
||||
RepoName string
|
||||
Session *ViewSession
|
||||
}
|
||||
|
||||
func handleSession(w http.ResponseWriter, r *http.Request, root, repo, sessionID string) {
|
||||
vs, err := LoadSession(root, repo, sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load session: %v", err), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Derive a display name
|
||||
name := filepath.Base(vs.Summary.CWD)
|
||||
if name == "." || name == "" {
|
||||
name = repo
|
||||
}
|
||||
|
||||
renderTemplate(w, "session.html", sessionPageData{
|
||||
EncodedRepo: repo,
|
||||
RepoName: name,
|
||||
Session: vs,
|
||||
})
|
||||
}
|
||||
107
internal/viewer/server.go
Normal file
107
internal/viewer/server.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package viewer
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/style.css
|
||||
var assets embed.FS
|
||||
|
||||
func StartServer(addr string) error {
|
||||
root, err := SessionsRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve sessions root: %w", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static assets (must be registered before "/" catch-all)
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS()))))
|
||||
|
||||
// Routes
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRepos(w, r, root)
|
||||
})
|
||||
mux.HandleFunc("/r/{repo}", func(w http.ResponseWriter, r *http.Request) {
|
||||
repo := r.PathValue("repo")
|
||||
if strings.Contains(repo, "..") || strings.Contains(repo, "/") {
|
||||
http.Error(w, "invalid repo path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
handleSessions(w, r, root, repo)
|
||||
})
|
||||
mux.HandleFunc("/r/{repo}/{sessionID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
repo := r.PathValue("repo")
|
||||
sid := r.PathValue("sessionID")
|
||||
if strings.Contains(repo, "..") || strings.Contains(sid, "..") {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
handleSession(w, r, root, repo, sid)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
fmt.Printf("\nOpen browser: http://%s\n", addr)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
func parseTemplate(name string) (*template.Template, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"formatDuration": formatDuration,
|
||||
"truncate": truncateText,
|
||||
"add": func(a, b int) int { return a + b },
|
||||
}
|
||||
content, err := assets.ReadFile("templates/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return template.New(name).Funcs(funcMap).Parse(string(content))
|
||||
}
|
||||
|
||||
func truncateText(n int, s string) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func renderTemplate(w http.ResponseWriter, name string, data any) {
|
||||
tmpl, err := parseTemplate(name)
|
||||
if err != nil {
|
||||
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
// Partially written — just log
|
||||
fmt.Printf("[viewer] template execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func staticFS() fs.FS {
|
||||
sub, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func formatDuration(seconds float64) string {
|
||||
d := time.Duration(seconds * float64(time.Second))
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.1fs", seconds)
|
||||
}
|
||||
minutes := int(d.Minutes())
|
||||
sec := int(d.Seconds()) - minutes*60
|
||||
return fmt.Sprintf("%dm%ds", minutes, sec)
|
||||
}
|
||||
207
internal/viewer/static/style.css
Normal file
207
internal/viewer/static/style.css
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
nav.breadcrumb {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
nav.breadcrumb a {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav.breadcrumb a:hover { text-decoration: underline; }
|
||||
|
||||
main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
|
||||
h2 { margin-bottom: 1rem; color: #24292e; }
|
||||
h3 { margin: 1.5rem 0 0.75rem; color: #24292e; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.table th {
|
||||
background: #f6f8fa;
|
||||
text-align: left;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #586069;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
}
|
||||
.table td {
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tr:hover { background: #f6f8fa; }
|
||||
.table a { color: #0366d6; text-decoration: none; }
|
||||
.table a:hover { text-decoration: underline; }
|
||||
.table code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.session-header h2 { margin-bottom: 0.5rem; word-break: break-all; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; color: #586069; }
|
||||
.meta code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Token usage summary */
|
||||
.token-summary {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.token-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.token-item { text-align: center; }
|
||||
.token-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #0366d6;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.token-label {
|
||||
font-size: 0.75rem;
|
||||
color: #586069;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.token-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.token-table th {
|
||||
background: #f6f8fa;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: #586069;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
}
|
||||
.token-table td {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
.token-table tr:last-child td { border-bottom: none; }
|
||||
.token-table tbody tr:hover { background: #f6f8fa; }
|
||||
|
||||
.file-list { list-style: none; padding-left: 1rem; }
|
||||
.file-list li {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #586069;
|
||||
}
|
||||
.file-list li::before { content: "\1F4C4 "; margin-right: 0.25rem; }
|
||||
|
||||
details {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: #24292e;
|
||||
user-select: none;
|
||||
}
|
||||
details summary:hover { background: #f6f8fa; }
|
||||
|
||||
.task-group { padding: 0.5rem 1rem 1rem; }
|
||||
.task-group h4 {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: #586069;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
background: #f6f8fa;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.badge {
|
||||
background: #dbedff;
|
||||
color: #0366d6;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.badge.error { background: #ffeef0; color: #cb2431; }
|
||||
|
||||
.card-body pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.request-preview { background: #fafbfc; border-bottom: 1px solid #e1e4e8; }
|
||||
.response-content { background: #fffbe6; }
|
||||
|
||||
.tool-calls { padding: 0.5rem; background: #f6f8fa; }
|
||||
.tool-call {
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tool-call.error { border-color: #d73a49; }
|
||||
.tool-call strong { color: #6f42c1; }
|
||||
.tool-args pre, .tool-result pre {
|
||||
font-size: 0.8rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tool-result { margin-top: 0.5rem; border-top: 1px solid #e1e4e8; padding-top: 0.5rem; }
|
||||
|
||||
p { color: #586069; padding: 1rem 0; }
|
||||
417
internal/viewer/store.go
Normal file
417
internal/viewer/store.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
// Package viewer provides a read-only WebUI for browsing session records
|
||||
// produced by open-code-review runs. It scans JSONL files under
|
||||
// $HOME/.open-code-review/sessions/, parses them, and exposes structured data.
|
||||
package viewer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionsRoot returns the root directory where session JSONL files are stored.
|
||||
func SessionsRoot() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".open-code-review", "sessions"), nil
|
||||
}
|
||||
|
||||
// RepoInfo represents a discovered repository from the sessions directory.
|
||||
type RepoInfo struct {
|
||||
EncodedPath string // encoded directory name on disk
|
||||
SessionCount int
|
||||
LastModified time.Time
|
||||
}
|
||||
|
||||
// DiscoverRepos walks the sessions root and returns one entry per subdirectory.
|
||||
func DiscoverRepos(root string) ([]RepoInfo, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read sessions dir: %w", err)
|
||||
}
|
||||
|
||||
var repos []RepoInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
repoDir := filepath.Join(root, e.Name())
|
||||
info := RepoInfo{EncodedPath: e.Name()}
|
||||
|
||||
subEntries, err := os.ReadDir(repoDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, se := range subEntries {
|
||||
if strings.HasSuffix(se.Name(), ".jsonl") {
|
||||
info.SessionCount++
|
||||
if fi, err := se.Info(); err == nil {
|
||||
if fi.ModTime().After(info.LastModified) {
|
||||
info.LastModified = fi.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.SessionCount > 0 {
|
||||
repos = append(repos, info)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(repos, func(i, j int) bool {
|
||||
return repos[i].LastModified.After(repos[j].LastModified)
|
||||
})
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// SessionSummary is built from session_start and session_end records.
|
||||
type SessionSummary struct {
|
||||
SessionID string
|
||||
Timestamp time.Time
|
||||
CWD string
|
||||
GitBranch string
|
||||
Model string
|
||||
FilesReviewed []string
|
||||
DurationSec float64
|
||||
FileCount int
|
||||
}
|
||||
|
||||
// ListSessions returns lightweight summaries for all sessions in a repo subdir.
|
||||
func ListSessions(root, encodedRepo string) ([]SessionSummary, error) {
|
||||
repoDir := filepath.Join(root, encodedRepo)
|
||||
entries, err := os.ReadDir(repoDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read repo dir: %w", err)
|
||||
}
|
||||
|
||||
var summaries []SessionSummary
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimSuffix(e.Name(), ".jsonl")
|
||||
s, err := peekSession(filepath.Join(repoDir, e.Name()))
|
||||
if err != nil {
|
||||
continue // skip unreadable files
|
||||
}
|
||||
s.SessionID = sessionID
|
||||
summaries = append(summaries, s)
|
||||
}
|
||||
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
return summaries[i].Timestamp.After(summaries[j].Timestamp)
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// peekSession reads only the first and last record of a JSONL file.
|
||||
func peekSession(path string) (SessionSummary, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return SessionSummary{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var summary SessionSummary
|
||||
scanner := bufio.NewScanner(f)
|
||||
buf := make([]byte, 0, 1024*1024)
|
||||
scanner.Buffer(buf, 10*1024*1024)
|
||||
|
||||
var lastLine []byte
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
lastLine = append([]byte(nil), line...)
|
||||
|
||||
if summary.Timestamp.IsZero() {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
if ts, ok := rec["timestamp"].(string); ok {
|
||||
summary.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
if cwd, ok := rec["cwd"].(string); ok {
|
||||
summary.CWD = cwd
|
||||
}
|
||||
if branch, ok := rec["gitBranch"].(string); ok {
|
||||
summary.GitBranch = branch
|
||||
}
|
||||
if model, ok := rec["model"].(string); ok {
|
||||
summary.Model = model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(lastLine) > 0 {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal(lastLine, &rec); err == nil {
|
||||
if typ, _ := rec["type"].(string); typ == "session_end" {
|
||||
if dur, ok := rec["duration_seconds"].(float64); ok {
|
||||
summary.DurationSec = dur
|
||||
}
|
||||
if files, ok := rec["files_reviewed"].([]any); ok {
|
||||
summary.FilesReviewed = make([]string, 0, len(files))
|
||||
for _, fv := range files {
|
||||
if s, ok := fv.(string); ok {
|
||||
summary.FilesReviewed = append(summary.FilesReviewed, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
summary.FileCount = len(summary.FilesReviewed)
|
||||
return summary, scanner.Err()
|
||||
}
|
||||
|
||||
// ViewSession holds fully parsed records for one session.
|
||||
type ViewSession struct {
|
||||
Summary SessionSummary
|
||||
TokenUsage TokenUsageSummary
|
||||
Files []*FileGroup // ordered by file path
|
||||
}
|
||||
|
||||
// TokenUsageSummary aggregates estimated token counts across the session.
|
||||
type TokenUsageSummary struct {
|
||||
TotalPromptTokens int
|
||||
TotalCompletionTokens int
|
||||
RequestCount int
|
||||
FileTokenBreakdown []FileTokenUsage // populated after parsing
|
||||
}
|
||||
|
||||
// FileTokenUsage tracks token totals for a single file within a session.
|
||||
type FileTokenUsage struct {
|
||||
FilePath string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
}
|
||||
|
||||
// FileGroup aggregates records for a single file.
|
||||
type FileGroup struct {
|
||||
FilePath string
|
||||
Tasks map[TaskType][]*TaskCard
|
||||
}
|
||||
|
||||
// TaskType mirrors session.TaskType.
|
||||
type TaskType string
|
||||
|
||||
const (
|
||||
PlanTask TaskType = "plan_task"
|
||||
MainTask TaskType = "main_task"
|
||||
MemoryCompressionTask TaskType = "memory_compression_task"
|
||||
)
|
||||
|
||||
// TaskCard links an LLM request with its response and tool calls.
|
||||
type TaskCard struct {
|
||||
RequestMessages any // preserved for display
|
||||
RequestNo int
|
||||
ResponseContent string
|
||||
ToolCalls []ToolCallInfo
|
||||
DurationMs int64
|
||||
Error string
|
||||
Model string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
}
|
||||
|
||||
// ToolCallInfo summarizes a single tool call.
|
||||
type ToolCallInfo struct {
|
||||
Name string
|
||||
Arguments string
|
||||
Result string
|
||||
Ok bool
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// LoadSession fully parses a JSONL file into a ViewSession.
|
||||
func LoadSession(root, encodedRepo, sessionID string) (*ViewSession, error) {
|
||||
path := filepath.Join(root, encodedRepo, sessionID+".jsonl")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open session file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
vs := &ViewSession{Files: make([]*FileGroup, 0)}
|
||||
fileIndex := make(map[string]*FileGroup)
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
buf := make([]byte, 0, 1024*1024)
|
||||
scanner.Buffer(buf, 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil {
|
||||
continue // skip malformed lines
|
||||
}
|
||||
typ, _ := rec["type"].(string)
|
||||
|
||||
switch typ {
|
||||
case "session_start":
|
||||
if ts, ok := rec["timestamp"].(string); ok {
|
||||
vs.Summary.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
if cwd, ok := rec["cwd"].(string); ok {
|
||||
vs.Summary.CWD = cwd
|
||||
}
|
||||
if branch, ok := rec["gitBranch"].(string); ok {
|
||||
vs.Summary.GitBranch = branch
|
||||
}
|
||||
if model, ok := rec["model"].(string); ok {
|
||||
vs.Summary.Model = model
|
||||
}
|
||||
|
||||
case "llm_request":
|
||||
fp, _ := rec["filePath"].(string)
|
||||
tt, _ := rec["taskType"].(string)
|
||||
reqNo := 0
|
||||
if n, ok := rec["request_no"].(float64); ok {
|
||||
reqNo = int(n)
|
||||
}
|
||||
msgs := rec["messages"]
|
||||
|
||||
tc := &TaskCard{RequestMessages: msgs, RequestNo: reqNo}
|
||||
|
||||
fg := fileIndex[fp]
|
||||
if fg == nil {
|
||||
fg = &FileGroup{FilePath: fp, Tasks: make(map[TaskType][]*TaskCard)}
|
||||
fileIndex[fp] = fg
|
||||
vs.Files = append(vs.Files, fg)
|
||||
}
|
||||
fg.Tasks[TaskType(tt)] = append(fg.Tasks[TaskType(tt)], tc)
|
||||
|
||||
case "llm_response":
|
||||
fp, _ := rec["filePath"].(string)
|
||||
content, _ := rec["content"].(string)
|
||||
durationMs := int64(0)
|
||||
if d, ok := rec["duration_ms"].(float64); ok {
|
||||
durationMs = int64(d)
|
||||
}
|
||||
model, _ := rec["model"].(string)
|
||||
errStr, _ := rec["error"].(string)
|
||||
|
||||
promptTok := 0
|
||||
completionTok := 0
|
||||
if usage, ok := rec["usage"].(map[string]any); ok {
|
||||
if v, ok := usage["prompt_tokens"].(float64); ok {
|
||||
promptTok = int(v)
|
||||
}
|
||||
if v, ok := usage["completion_tokens"].(float64); ok {
|
||||
completionTok = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
tt, _ := rec["taskType"].(string)
|
||||
fg := fileIndex[fp]
|
||||
if fg != nil {
|
||||
cards := fg.Tasks[TaskType(tt)]
|
||||
if len(cards) > 0 && cards[len(cards)-1].ResponseContent == "" {
|
||||
card := cards[len(cards)-1]
|
||||
card.ResponseContent = content
|
||||
card.DurationMs = durationMs
|
||||
card.Model = model
|
||||
card.Error = errStr
|
||||
card.PromptTokens = promptTok
|
||||
card.CompletionTokens = completionTok
|
||||
}
|
||||
}
|
||||
|
||||
// Also attach tool_calls to the same card
|
||||
if tcs, ok := rec["tool_calls"].([]any); ok && fg != nil {
|
||||
tt, _ := rec["taskType"].(string)
|
||||
cards := fg.Tasks[TaskType(tt)]
|
||||
if len(cards) > 0 {
|
||||
card := cards[len(cards)-1]
|
||||
for _, tc := range tcs {
|
||||
if tm, ok := tc.(map[string]any); ok {
|
||||
name, _ := tm["name"].(string)
|
||||
args, _ := tm["arguments"].(string)
|
||||
card.ToolCalls = append(card.ToolCalls, ToolCallInfo{
|
||||
Name: name, Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "tool_call":
|
||||
result, _ := rec["result"].(string)
|
||||
okVal := true
|
||||
if b, hasOk := rec["ok"].(bool); hasOk {
|
||||
okVal = b
|
||||
}
|
||||
fp, _ := rec["filePath"].(string)
|
||||
tt, _ := rec["taskType"].(string)
|
||||
durationMs := int64(0)
|
||||
if d, ok2 := rec["duration_ms"].(float64); ok2 {
|
||||
durationMs = int64(d)
|
||||
}
|
||||
|
||||
fg := fileIndex[fp]
|
||||
if fg != nil {
|
||||
cards := fg.Tasks[TaskType(tt)]
|
||||
if len(cards) > 0 {
|
||||
card := cards[len(cards)-1]
|
||||
ti := len(card.ToolCalls) - 1
|
||||
if ti >= 0 && card.ToolCalls[ti].Result == "" {
|
||||
card.ToolCalls[ti].Result = result
|
||||
card.ToolCalls[ti].Ok = okVal
|
||||
card.ToolCalls[ti].DurationMs = durationMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "session_end":
|
||||
if dur, ok := rec["duration_seconds"].(float64); ok {
|
||||
vs.Summary.DurationSec = dur
|
||||
}
|
||||
if files, ok := rec["files_reviewed"].([]any); ok {
|
||||
vs.Summary.FilesReviewed = make([]string, 0, len(files))
|
||||
for _, fv := range files {
|
||||
if s, ok2 := fv.(string); ok2 {
|
||||
vs.Summary.FilesReviewed = append(vs.Summary.FilesReviewed, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
vs.Summary.FileCount = len(vs.Summary.FilesReviewed)
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate token usage across all task cards
|
||||
fileBreakdown := make([]FileTokenUsage, 0, len(vs.Files))
|
||||
for _, fg := range vs.Files {
|
||||
ft := FileTokenUsage{FilePath: fg.FilePath}
|
||||
for _, cards := range fg.Tasks {
|
||||
for _, c := range cards {
|
||||
vs.TokenUsage.TotalPromptTokens += c.PromptTokens
|
||||
vs.TokenUsage.TotalCompletionTokens += c.CompletionTokens
|
||||
if c.ResponseContent != "" || c.PromptTokens > 0 {
|
||||
vs.TokenUsage.RequestCount++
|
||||
}
|
||||
ft.PromptTokens += c.PromptTokens
|
||||
ft.CompletionTokens += c.CompletionTokens
|
||||
}
|
||||
}
|
||||
fileBreakdown = append(fileBreakdown, ft)
|
||||
}
|
||||
sort.Slice(fileBreakdown, func(i, j int) bool {
|
||||
return fileBreakdown[i].PromptTokens+fileBreakdown[i].CompletionTokens > fileBreakdown[j].PromptTokens+fileBreakdown[j].CompletionTokens
|
||||
})
|
||||
vs.TokenUsage.FileTokenBreakdown = fileBreakdown
|
||||
|
||||
sort.Slice(vs.Files, func(i, j int) bool {
|
||||
return vs.Files[i].FilePath < vs.Files[j].FilePath
|
||||
})
|
||||
|
||||
vs.Summary.SessionID = sessionID
|
||||
return vs, scanner.Err()
|
||||
}
|
||||
31
internal/viewer/templates/repos.html
Normal file
31
internal/viewer/templates/repos.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Repositories - Open Code Review Viewer</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="breadcrumb"><a href="/">Open Code Review Viewer</a></nav>
|
||||
<main>
|
||||
<h2>Repositories</h2>
|
||||
{{if .Repos}}
|
||||
<table class="table">
|
||||
<thead><tr><th>Repository</th><th>Sessions</th><th>Last Modified</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Repos}}
|
||||
<tr>
|
||||
<td><a href="/r/{{.EncodedPath}}">{{.EncodedPath}}</a></td>
|
||||
<td>{{.SessionCount}}</td>
|
||||
<td>{{.LastModified.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>No session data found. Run a code review first.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
112
internal/viewer/templates/session.html
Normal file
112
internal/viewer/templates/session.html
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Detail - Open Code Review Viewer</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="breadcrumb"><a href="/">Open Code Review Viewer</a> › <a href="/r/{{.EncodedRepo}}">{{.RepoName}}</a> › Session {{.Session.Summary.SessionID | truncate 12}}</nav>
|
||||
<main>
|
||||
<div class="session-header">
|
||||
<h2>Session: {{.Session.Summary.SessionID}}</h2>
|
||||
<div class="meta">
|
||||
<span><strong>CWD:</strong> {{.Session.Summary.CWD}}</span>
|
||||
<span><strong>Branch:</strong> {{.Session.Summary.GitBranch}}</span>
|
||||
<span><strong>Model:</strong> <code>{{.Session.Summary.Model}}</code></span>
|
||||
<span><strong>Duration:</strong> {{formatDuration .Session.Summary.DurationSec}}</span>
|
||||
<span><strong>Files:</strong> {{.Session.Summary.FileCount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="token-summary">
|
||||
<h3>Token Usage</h3>
|
||||
<div class="token-stats">
|
||||
<div class="token-item">
|
||||
<div class="token-value">{{.Session.TokenUsage.TotalPromptTokens}}</div>
|
||||
<div class="token-label">Prompt Tokens</div>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<div class="token-value">{{.Session.TokenUsage.TotalCompletionTokens}}</div>
|
||||
<div class="token-label">Completion Tokens</div>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<div class="token-value">{{add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens}}</div>
|
||||
<div class="token-label">Total Tokens</div>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<div class="token-value">{{.Session.TokenUsage.RequestCount}}</div>
|
||||
<div class="token-label">LLM Requests</div>
|
||||
</div>
|
||||
</div>
|
||||
{{with .Session.TokenUsage.FileTokenBreakdown}}
|
||||
<table class="token-table">
|
||||
<thead><tr><th>File</th><th>Prompt</th><th>Completion</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td title="{{.FilePath}}">{{.FilePath | truncate 60}}</td>
|
||||
<td>{{.PromptTokens}}</td>
|
||||
<td>{{.CompletionTokens}}</td>
|
||||
<td><strong>{{add .PromptTokens .CompletionTokens}}</strong></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Session.Summary.FilesReviewed}}
|
||||
<h3>Files Reviewed</h3>
|
||||
<ul class="file-list">
|
||||
{{range .Session.Summary.FilesReviewed}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<h3>Conversations ({{len .Session.Files}} files)</h3>
|
||||
{{range .Session.Files}}
|
||||
<details>
|
||||
<summary><strong>{{.FilePath}}</strong></summary>
|
||||
<div class="task-group">
|
||||
{{range $tt, $cards := .Tasks}}
|
||||
<h4>{{$tt}}</h4>
|
||||
{{range $cards}}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>Request #{{.RequestNo}}</span>
|
||||
{{if .Model}}<span class="badge">{{.Model}}</span>{{end}}
|
||||
{{if or .PromptTokens .CompletionTokens}}<span class="badge">P:{{.PromptTokens}} C:{{.CompletionTokens}}</span>{{end}}
|
||||
{{if .DurationMs}}<span class="badge">{{.DurationMs}}ms</span>{{end}}
|
||||
{{if .Error}}<span class="badge error">{{.Error}}</span>{{end}}
|
||||
</div>
|
||||
{{with .ResponseContent}}
|
||||
<div class="card-body response-content">
|
||||
<pre>{{.}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ToolCalls}}
|
||||
<div class="tool-calls">
|
||||
{{range .ToolCalls}}
|
||||
<div class="tool-call {{if not .Ok}}error{{end}}">
|
||||
<strong>{{.Name}}</strong>
|
||||
<details>
|
||||
<summary>Arguments & Result</summary>
|
||||
<div class="tool-args"><pre>{{.Arguments}}</pre></div>
|
||||
<div class="tool-result"><pre>{{.Result}}</pre></div>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
34
internal/viewer/templates/sessions.html
Normal file
34
internal/viewer/templates/sessions.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sessions - Open Code Review Viewer</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="breadcrumb"><a href="/">Open Code Review Viewer</a> › <a href="/r/{{.EncodedRepo}}">{{.RepoName}}</a></nav>
|
||||
<main>
|
||||
<h2>Sessions: {{.RepoName}}</h2>
|
||||
{{if .Sessions}}
|
||||
<table class="table">
|
||||
<thead><tr><th>Session ID</th><th>Branch</th><th>Model</th><th>Files</th><th>Duration</th><th>Started At</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sessions}}
|
||||
<tr>
|
||||
<td><a href="/r/{{$.EncodedRepo}}/{{.SessionID}}">{{.SessionID | printf "%.8s"}}…</a></td>
|
||||
<td>{{.GitBranch}}</td>
|
||||
<td><code>{{.Model}}</code></td>
|
||||
<td>{{.FileCount}}</td>
|
||||
<td>{{formatDuration .DurationSec}}</td>
|
||||
<td>{{.Timestamp.Format "2006-01-02 15:04"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>No sessions found for this repository.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue