feat(preview): agent driven browser previews + Markdown rendering (#2455)
* feat(preview): render Markdown and let agents drive the browser panel via ao preview * fix go linting errors * fix linting errors * refactor(preview): fix the auto open bug
This commit is contained in:
parent
9cda5d4bcc
commit
cc7754e176
|
|
@ -14,6 +14,7 @@ require (
|
|||
github.com/spf13/pflag v1.0.9
|
||||
github.com/swaggest/jsonschema-go v0.3.79
|
||||
github.com/swaggest/openapi-go v0.2.61
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/sys v0.44.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO
|
|||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
|
|
|
|||
|
|
@ -189,9 +189,32 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
|
|||
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
|
||||
return
|
||||
}
|
||||
if previewutil.IsMarkdownPath(file) {
|
||||
c.servePreviewMarkdown(w, r, file)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
|
||||
// servePreviewMarkdown renders a workspace Markdown file to a self-contained
|
||||
// HTML document so the browser panel displays formatted content instead of raw
|
||||
// source.
|
||||
func (c *SessionsController) servePreviewMarkdown(w http.ResponseWriter, r *http.Request, file string) {
|
||||
source, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
|
||||
return
|
||||
}
|
||||
rendered, err := previewutil.RenderMarkdown(source, filepath.Base(file))
|
||||
if err != nil {
|
||||
envelope.WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
_, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted
|
||||
}
|
||||
|
||||
// setPreview persists the browser preview URL the desktop app opens for a
|
||||
// session and fans out a session_updated CDC event so the dashboard's browser
|
||||
// panel reacts live. The target is resolved as follows:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package preview
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
|
|
@ -13,6 +14,19 @@ import (
|
|||
|
||||
var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"}
|
||||
|
||||
// previewableExts are the file extensions the browser panel can render: HTML
|
||||
// verbatim and Markdown converted to HTML by the preview/files route.
|
||||
var previewableExts = map[string]struct{}{
|
||||
".html": {},
|
||||
".htm": {},
|
||||
".md": {},
|
||||
".markdown": {},
|
||||
}
|
||||
|
||||
// maxPreviewWalkFiles bounds the most-recent fallback scan so a pathological
|
||||
// workspace cannot stall the preview poller.
|
||||
const maxPreviewWalkFiles = 5000
|
||||
|
||||
// Entry is a workspace-local static frontend entrypoint.
|
||||
type Entry struct {
|
||||
Path string
|
||||
|
|
@ -21,8 +35,11 @@ type Entry struct {
|
|||
Size int64
|
||||
}
|
||||
|
||||
// DiscoverEntry returns the first supported HTML entrypoint that exists inside
|
||||
// the workspace.
|
||||
// DiscoverEntry returns the entry the browser panel should preview for a
|
||||
// workspace. A conventional index.html (or its public/dist/build variants)
|
||||
// always wins; when none exists it falls back to the most-recently-modified
|
||||
// previewable file (.html/.htm/.md/.markdown) anywhere in the workspace, so a
|
||||
// freshly generated report or document shows up automatically.
|
||||
func DiscoverEntry(workspacePath string) (Entry, bool) {
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return Entry{}, false
|
||||
|
|
@ -37,7 +54,82 @@ func DiscoverEntry(workspacePath string) (Entry, bool) {
|
|||
return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true
|
||||
}
|
||||
}
|
||||
return Entry{}, false
|
||||
return mostRecentPreviewable(workspacePath)
|
||||
}
|
||||
|
||||
// mostRecentPreviewable walks the workspace and returns the newest previewable
|
||||
// file. Ties (equal mod times) break on the slash path so the result is
|
||||
// deterministic. Hidden directories and node_modules are skipped, and the scan
|
||||
// is bounded by maxPreviewWalkFiles.
|
||||
func mostRecentPreviewable(workspacePath string) (Entry, bool) {
|
||||
root, err := filepath.Abs(workspacePath)
|
||||
if err != nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
var best Entry
|
||||
found := false
|
||||
seen := 0
|
||||
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
//nolint:nilerr // skip unreadable entries rather than aborting the whole scan
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if p != root && skipPreviewDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, ok := previewableExts[strings.ToLower(filepath.Ext(d.Name()))]; !ok {
|
||||
return nil
|
||||
}
|
||||
seen++
|
||||
if seen > maxPreviewWalkFiles {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
//nolint:nilerr // skip this file, keep scanning the rest of the workspace
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
//nolint:nilerr // skip this file, keep scanning the rest of the workspace
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if !found || newerPreviewable(info, relSlash, best) {
|
||||
best = Entry{Path: relSlash, AbsPath: p, ModTime: info.ModTime(), Size: info.Size()}
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return best, found
|
||||
}
|
||||
|
||||
func newerPreviewable(info fs.FileInfo, relSlash string, best Entry) bool {
|
||||
mod := info.ModTime()
|
||||
if mod.After(best.ModTime) {
|
||||
return true
|
||||
}
|
||||
if mod.Equal(best.ModTime) {
|
||||
return relSlash < best.Path
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func skipPreviewDir(name string) bool {
|
||||
return strings.HasPrefix(name, ".") || name == "node_modules"
|
||||
}
|
||||
|
||||
// IsMarkdownPath reports whether p names a Markdown file the preview/files
|
||||
// route should render to HTML rather than serve verbatim.
|
||||
func IsMarkdownPath(p string) bool {
|
||||
switch strings.ToLower(filepath.Ext(p)) {
|
||||
case ".md", ".markdown":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ConfinedPath maps an asset path into workspacePath and rejects paths that
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package preview
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeEntryFile(t *testing.T, path, contents string, mod time.Time) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
if !mod.IsZero() {
|
||||
if err := os.Chtimes(path, mod, mod); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntryPrefersIndexOverNewerFile(t *testing.T) {
|
||||
ws := t.TempDir()
|
||||
base := time.Now()
|
||||
writeEntryFile(t, filepath.Join(ws, "index.html"), "<main>app</main>", base)
|
||||
// A newer report must not win against the conventional index.html anchor.
|
||||
writeEntryFile(t, filepath.Join(ws, "report.html"), "<main>report</main>", base.Add(time.Hour))
|
||||
|
||||
entry, ok := DiscoverEntry(ws)
|
||||
if !ok {
|
||||
t.Fatal("DiscoverEntry: ok=false, want entry")
|
||||
}
|
||||
if entry.Path != "index.html" {
|
||||
t.Fatalf("entry.Path = %q, want index.html", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntryFallsBackToMostRecentPreviewable(t *testing.T) {
|
||||
ws := t.TempDir()
|
||||
base := time.Now()
|
||||
writeEntryFile(t, filepath.Join(ws, "old.html"), "<main>old</main>", base)
|
||||
writeEntryFile(t, filepath.Join(ws, "docs", "notes.md"), "# notes", base.Add(30*time.Minute))
|
||||
writeEntryFile(t, filepath.Join(ws, "fresh.html"), "<main>fresh</main>", base.Add(time.Hour))
|
||||
|
||||
entry, ok := DiscoverEntry(ws)
|
||||
if !ok {
|
||||
t.Fatal("DiscoverEntry: ok=false, want fallback entry")
|
||||
}
|
||||
if entry.Path != "fresh.html" {
|
||||
t.Fatalf("entry.Path = %q, want fresh.html", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntryFallsBackToMarkdown(t *testing.T) {
|
||||
ws := t.TempDir()
|
||||
writeEntryFile(t, filepath.Join(ws, "REPORT.md"), "# report", time.Now())
|
||||
|
||||
entry, ok := DiscoverEntry(ws)
|
||||
if !ok {
|
||||
t.Fatal("DiscoverEntry: ok=false, want markdown fallback")
|
||||
}
|
||||
if entry.Path != "REPORT.md" {
|
||||
t.Fatalf("entry.Path = %q, want REPORT.md", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntrySkipsHiddenAndNodeModules(t *testing.T) {
|
||||
ws := t.TempDir()
|
||||
base := time.Now()
|
||||
// Newest files live in skipped dirs; the visible one must win.
|
||||
writeEntryFile(t, filepath.Join(ws, "node_modules", "pkg", "index.html"), "x", base.Add(time.Hour))
|
||||
writeEntryFile(t, filepath.Join(ws, ".cache", "cached.html"), "x", base.Add(2*time.Hour))
|
||||
writeEntryFile(t, filepath.Join(ws, "visible.html"), "ok", base)
|
||||
|
||||
entry, ok := DiscoverEntry(ws)
|
||||
if !ok {
|
||||
t.Fatal("DiscoverEntry: ok=false, want visible entry")
|
||||
}
|
||||
if entry.Path != "visible.html" {
|
||||
t.Fatalf("entry.Path = %q, want visible.html", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntryTieBreaksOnPath(t *testing.T) {
|
||||
ws := t.TempDir()
|
||||
mod := time.Now()
|
||||
writeEntryFile(t, filepath.Join(ws, "b.html"), "b", mod)
|
||||
writeEntryFile(t, filepath.Join(ws, "a.html"), "a", mod)
|
||||
|
||||
entry, ok := DiscoverEntry(ws)
|
||||
if !ok {
|
||||
t.Fatal("DiscoverEntry: ok=false, want tie-break entry")
|
||||
}
|
||||
if entry.Path != "a.html" {
|
||||
t.Fatalf("entry.Path = %q, want a.html (lexical tie-break)", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntryEmptyWorkspace(t *testing.T) {
|
||||
if _, ok := DiscoverEntry(t.TempDir()); ok {
|
||||
t.Fatal("DiscoverEntry: ok=true for empty workspace, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMarkdownPath(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"a.md": true,
|
||||
"A.MARKDOWN": true,
|
||||
"dir/x.md": true,
|
||||
"index.html": false,
|
||||
"notes.txt": false,
|
||||
"noext": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsMarkdownPath(in); got != want {
|
||||
t.Errorf("IsMarkdownPath(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
ghtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
// markdownRenderer converts workspace Markdown to HTML for the browser panel.
|
||||
// GitHub-flavored extensions (tables, strikethrough, task lists, autolinks) are
|
||||
// enabled, and raw HTML is passed through: preview content is workspace-local
|
||||
// and agent-trusted, matching the preview target's existing trust model.
|
||||
var markdownRenderer = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithRendererOptions(ghtml.WithUnsafe()),
|
||||
)
|
||||
|
||||
// RenderMarkdown converts Markdown source into a self-contained HTML document
|
||||
// styled for the browser panel. title labels the document (typically the file
|
||||
// name).
|
||||
func RenderMarkdown(source []byte, title string) ([]byte, error) {
|
||||
var body bytes.Buffer
|
||||
if err := markdownRenderer.Convert(source, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.WriteString("<!doctype html>\n<html lang=\"en\">\n<head>\n")
|
||||
out.WriteString("<meta charset=\"utf-8\">\n")
|
||||
out.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
|
||||
out.WriteString("<title>")
|
||||
out.WriteString(html.EscapeString(title))
|
||||
out.WriteString("</title>\n<style>")
|
||||
out.WriteString(markdownStyles)
|
||||
out.WriteString("</style>\n</head>\n<body>\n<main class=\"markdown-body\">\n")
|
||||
out.Write(body.Bytes())
|
||||
out.WriteString("\n</main>\n</body>\n</html>\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// markdownStyles is a compact GitHub-like stylesheet that honors the OS color
|
||||
// scheme so the rendered Markdown fits the desktop browser panel in both light
|
||||
// and dark modes.
|
||||
const markdownStyles = `
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
color: #1f2328;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.markdown-body {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 64px;
|
||||
}
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3,
|
||||
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
||||
margin: 1.5em 0 0.6em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.markdown-body h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid rgba(128,128,128,0.25); }
|
||||
.markdown-body h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid rgba(128,128,128,0.25); }
|
||||
.markdown-body h3 { font-size: 1.25em; }
|
||||
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body table {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.markdown-body a { color: #3b82f6; text-decoration: none; }
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
.markdown-body code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(128,128,128,0.15);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: rgba(128,128,128,0.12);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown-body pre code { background: none; padding: 0; }
|
||||
.markdown-body blockquote {
|
||||
padding: 0 1em;
|
||||
border-left: 4px solid rgba(128,128,128,0.35);
|
||||
color: rgba(120,120,120,1);
|
||||
}
|
||||
.markdown-body table { border-collapse: collapse; width: auto; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid rgba(128,128,128,0.3); padding: 6px 13px; }
|
||||
.markdown-body img { max-width: 100%; }
|
||||
.markdown-body hr { border: none; border-top: 1px solid rgba(128,128,128,0.25); margin: 2em 0; }
|
||||
.markdown-body input[type="checkbox"] { margin-right: 0.5em; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #0d1117; color: #e6edf3; }
|
||||
}
|
||||
`
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package preview
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownProducesHTMLDocument(t *testing.T) {
|
||||
out, err := RenderMarkdown([]byte("# Title\n\nHello **world**\n"), "notes.md")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderMarkdown: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, want := range []string{"<!doctype html>", "<title>notes.md</title>", "<h1", "Title", "<strong>world</strong>"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("rendered output missing %q\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownGFMTable(t *testing.T) {
|
||||
src := "| a | b |\n| - | - |\n| 1 | 2 |\n"
|
||||
out, err := RenderMarkdown([]byte(src), "table.md")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderMarkdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "<table>") {
|
||||
t.Errorf("GFM table not rendered:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownEscapesTitle(t *testing.T) {
|
||||
out, err := RenderMarkdown([]byte("hi"), "<x>.md")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderMarkdown: %v", err)
|
||||
}
|
||||
if strings.Contains(string(out), "<title><x>.md</title>") {
|
||||
t.Errorf("title not escaped:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), "<x>.md") {
|
||||
t.Errorf("expected escaped title, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue