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>
107 lines
3.8 KiB
Go
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)))
|
|
}
|
|
}
|