Files
llmgw/internal/web/chatrender.go
T
Ilker ManapandClaude Opus 5.5 6b1f8b0827 Render a finished chat reply as markdown
A reply streams as text as before; once it ends, the server renders it
with the model card's rules - raw HTML dropped, dangerous links emptied,
images as links - and the page places that. Headings, lists, tables and
code blocks read as they were written.

Maths outside code is set aside before the markdown, so its underscores
and asterisks are not taken for emphasis, and marked for the page to
typeset. KaTeX no longer searches the reply for delimiters, which took
"$5 and $10" for a formula. A reply that cannot be rendered stays
plain text, with the old search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:44:18 +02:00

107 lines
3.8 KiB
Go

package web
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"regexp"
"strings"
)
// mathSpan finds what KaTeX will typeset, in the order its delimiters are
// tried on the page: display before inline, and a single $ last.
//
// A single-$ span must not start or end with a space. That is what keeps
// "$5 and $10" a sentence about prices rather than a formula, as KaTeX's own
// rule does.
var mathSpan = regexp.MustCompile(`(?s)\$\$.+?\$\$|\\\[.+?\\\]|\\\(.+?\\\)|\$[^\s$](?:[^$\n]*?[^\s$])?\$`)
// codeSpan finds fenced code blocks and inline code, which maths is not
// looked for in. A fence left open — a reply cut off mid-block — is not
// matched, and is read as text.
var codeSpan = regexp.MustCompile("(?s)(?:^|\n)[ \t]*(```|~~~).*?\n[ \t]*(?:```|~~~)[^\n]*|`[^`\n]+`")
// renderReply turns a model's reply into HTML for the chat page.
//
// Markdown with the model card's rules — raw HTML dropped, dangerous links
// emptied, images shown as links — with the maths set aside first. Read as
// markdown, "a_1 * b_2" loses its underscores to emphasis before KaTeX ever
// sees it; set aside, it reaches the page as the text the model wrote, for
// KaTeX to typeset there.
func renderReply(reply string) template.HTML {
var spans []string
protect := func(text string) string {
return mathSpan.ReplaceAllStringFunc(text, func(m string) string {
spans = append(spans, m)
// Letters and digits only, so no markdown rule touches it.
return fmt.Sprintf("llmgwmath%dx", len(spans)-1)
})
}
// Only outside code: "echo $HOME $PATH" in a code block is shell, and a
// formula quoted in backticks is being shown, not typeset.
var b strings.Builder
last := 0
for _, c := range codeSpan.FindAllStringIndex(reply, -1) {
b.WriteString(protect(reply[last:c[0]]))
b.WriteString(reply[c[0]:c[1]])
last = c[1]
}
b.WriteString(protect(reply[last:]))
protected := b.String()
out := string(safeMarkdown(protected, ""))
for i, m := range spans {
out = strings.Replace(out, fmt.Sprintf("llmgwmath%dx", i), mathElement(m), 1)
}
return template.HTML(out) //nolint:gosec // goldmark's safe output, maths escaped
}
// mathElement marks one span of maths for the page to typeset, its
// delimiters removed and the TeX escaped as text.
//
// Marked here rather than found again in the page: KaTeX's own search, run
// over the reply, took "$5 and $10" for a formula. Deciding once, on the
// server, with the stricter rule above, means the page typesets exactly what
// was set aside and nothing else. Inside a code block the mark is shown as
// the text it replaced, since that is all a code block holds.
func mathElement(m string) string {
tex, display := m, false
switch {
case strings.HasPrefix(m, "$$"):
tex, display = m[2:len(m)-2], true
case strings.HasPrefix(m, `\[`):
tex, display = m[2:len(m)-2], true
case strings.HasPrefix(m, `\(`):
tex = m[2 : len(m)-2]
default:
tex = m[1 : len(m)-1]
}
mode := "0"
if display {
mode = "1"
}
return `<span class="math" data-display="` + mode + `" data-src="` +
template.HTMLEscapeString(m) + `">` + template.HTMLEscapeString(tex) + `</span>`
}
// ReplyRenderer answers the chat page's request to render a finished reply.
//
// On the server rather than in the page: the markdown rules are then the
// model card's, already written and tested, and the page carries no parser
// of its own.
func ReplyRenderer(maxBody int64) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in struct {
Content string `json:"content"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil || json.Unmarshal(body, &in) != nil {
http.Error(w, "send the reply as {\"content\": ...}", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, string(renderReply(in.Content)))
}
}