feat: 增加viewer能力

This commit is contained in:
kite 2026-04-30 16:05:19 +08:00
parent 576180d32b
commit d99e3eb969
9 changed files with 1045 additions and 0 deletions

View file

@ -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:

View 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`)
}

View 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
View 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)
}

View 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
View 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()
}

View 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>

View 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> &rsaquo; <a href="/r/{{.EncodedRepo}}">{{.RepoName}}</a> &rsaquo; 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 &amp; 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>

View 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> &rsaquo; <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>