Render maths in the chat, without letting the model write markup
CI / test (push) Successful in 1m20s
CI / test (push) Successful in 1m20s
Replies are plain text, so a model asked about an integral answers with \int_0^1 x^2 dx and the reader gets the source. KaTeX is vendored into the binary rather than loaded from a CDN. This gateway makes no outbound requests of its own and is expected to run where it could not make one; a chat page that silently loses its maths on an air-gapped cluster would be worse than one that never had it. 600 KB of JS, CSS and WOFF2 fonts, which the stripped binary absorbs without changing its rounded size. The ordering is the safety argument, and there is a test for it rather than a comment alone. The reply is put in place with textContent exactly as before, and only then walked for maths: renderMathInElement replaces the spans it recognises, so nothing the model wrote is ever parsed as HTML and the markup that appears is KaTeX's. trust stays false — KaTeX's default, and what keeps \href and \includegraphics out of a reply — and throwOnError is off so one malformed expression does not take the rest with it. Rendered once the reply is complete. Typesetting each chunk would try to set half an expression on every token and flicker through the whole answer. The static handler now serves a small embedded asset tree rather than one file, so it moved to its own embed: a file server over the tree that also carries the templates would serve those too. Directory listings are refused — nothing links to one, so the only requests are from somebody looking around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b67b1fe42
commit
cbf276a41b
29 files changed
+240
-21
No files matched your search
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func chatPage(t *testing.T, models []string) string {
|
||||
t.Helper()
|
||||
h := Chat(store.User{Email: "a@example.com", ProjectID: "proj"}, models, "v1")
|
||||
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
func TestMathIsRenderedWithoutTrustingTheModel(t *testing.T) {
|
||||
// The ordering in the page is the safety argument, so it is worth asserting
|
||||
// rather than remembering: the reply is put in place with textContent and
|
||||
// only then walked for maths. KaTeX replaces the spans it recognises, so
|
||||
// nothing the model wrote is ever parsed as HTML.
|
||||
page := chatPage(t, []string{"m"})
|
||||
|
||||
if !strings.Contains(page, "/_static/katex/katex.min.css") ||
|
||||
!strings.Contains(page, "/_static/katex/auto-render.min.js") {
|
||||
t.Fatal("the page does not load KaTeX")
|
||||
}
|
||||
// Vendored, not fetched: this gateway makes no outbound requests and is
|
||||
// expected to run where it could not.
|
||||
for _, cdn := range []string{"cdn.jsdelivr", "unpkg.com", "cdnjs.", "//katex.org"} {
|
||||
if strings.Contains(page, cdn) {
|
||||
t.Errorf("the page loads KaTeX from %s instead of the binary", cdn)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(page, "trust: false") {
|
||||
t.Error("trust is not pinned off; \\href in model output would become a link")
|
||||
}
|
||||
if !strings.Contains(page, "throwOnError: false") {
|
||||
t.Error("a single malformed expression would take the whole reply with it")
|
||||
}
|
||||
|
||||
// The reply still reaches the page as text first.
|
||||
if !strings.Contains(page, "out.textContent = reply") {
|
||||
t.Error("the reply is no longer written with textContent")
|
||||
}
|
||||
if strings.Contains(page, "innerHTML") {
|
||||
t.Error("the chat page assigns innerHTML somewhere")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMathIsRenderedOnceTheReplyIsComplete(t *testing.T) {
|
||||
// Typesettingevery chunk would try to render half an expression on every token
|
||||
// and flicker through the whole reply.
|
||||
page := chatPage(t, []string{"m"})
|
||||
body := page[strings.Index(page, "form.onsubmit"):]
|
||||
|
||||
renderAt := strings.Index(body, "renderMath(out)")
|
||||
streamAt := strings.Index(body, "out.textContent = reply")
|
||||
if renderAt < 0 || streamAt < 0 {
|
||||
t.Fatal("the streaming loop or the render call is missing")
|
||||
}
|
||||
if renderAt < streamAt {
|
||||
t.Error("maths is rendered before the text that carries it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayDelimitersComeBeforeInlineOnes(t *testing.T) {
|
||||
// $$…$$ has to be tried before $…$, or the opening $$ is read as an empty
|
||||
// inline expression and everything after it is wrong.
|
||||
page := chatPage(t, []string{"m"})
|
||||
dd := strings.Index(page, `left: "$$"`)
|
||||
d := strings.Index(page, `left: "$"`)
|
||||
if dd < 0 || d < 0 {
|
||||
t.Fatal("the delimiters are missing")
|
||||
}
|
||||
if dd > d {
|
||||
t.Error(`$ is tried before $$`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChatPageOffersWhatItWasGiven(t *testing.T) {
|
||||
page := chatPage(t, []string{"alpha", "beta"})
|
||||
for _, m := range []string{"alpha", "beta"} {
|
||||
if !strings.Contains(page, `value="`+m+`"`) {
|
||||
t.Errorf("the picker does not offer %s", m)
|
||||
}
|
||||
}
|
||||
empty := chatPage(t, nil)
|
||||
if strings.Contains(empty, "<form id=\"composer\"") {
|
||||
t.Error("a composer was rendered with no model to send to")
|
||||
}
|
||||
if !strings.Contains(empty, "No models are available") {
|
||||
t.Error("the page does not explain why it is empty")
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,10 @@ select { font: inherit; padding: .35rem .4rem; border: 1px solid var(--line);
|
||||
.chat-composer { display: flex; gap: .6rem; margin-top: .75rem; align-items: flex-end; }
|
||||
.chat-composer textarea { flex: 1; font: inherit; padding: .5rem .6rem; resize: vertical;
|
||||
border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
|
||||
|
||||
/* KaTeX sits inside a bubble that sets white-space: pre-wrap for plain text;
|
||||
display maths needs to escape that or every fraction gets the newlines in
|
||||
its own source. */
|
||||
.bubble .katex-display { white-space: normal; margin: .6rem 0; overflow-x: auto; }
|
||||
.bubble .katex { white-space: normal; }
|
||||
.bubble.you .katex { color: inherit; }
|
||||
@@ -0,0 +1,26 @@
|
||||
KaTeX 0.16.11 — https://katex.org — MIT License
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Vendored rather than loaded from a CDN. This gateway makes no outbound requests
|
||||
of its own and is expected to run on networks that cannot reach one; a chat page
|
||||
that silently loses its maths on an air-gapped cluster would be worse than one
|
||||
that never had it.
|
||||
|
||||
Taken unmodified from the npm tarball katex-0.16.11.tgz:
|
||||
dist/katex.min.js
|
||||
dist/katex.min.css
|
||||
dist/contrib/auto-render.min.js
|
||||
dist/fonts/*.woff2
|
||||
|
||||
Only the WOFF2 fonts are included; the CSS also lists WOFF and TTF sources,
|
||||
which will 404. Every browser released since about 2016 takes the WOFF2 and
|
||||
never asks for the others, and carrying three copies of the same fonts would
|
||||
triple the 300 KB this costs.
|
||||
|
||||
To update: fetch the tarball, replace these files, and check that
|
||||
TestMathIsRenderedWithoutTrustingTheModel still passes — KaTeX's `trust` option
|
||||
defaults to false, and that default is what keeps \href and friends out of model
|
||||
output.
|
||||
7b57d427ac6270677daf8d8380ded2cc73336f9149a167b8e1fe0d6ef66604ae auto-render.min.js
|
||||
e6bfe5deebd4c7ccd272055bab63bd3ab2c73b907b6e6a22d352740a81381fd4 katex.min.js
|
||||
717bc9ae7853b61f0f76455dddf0ecd4f527a783f42de2ac24684899c1c46258 katex.min.css
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={771:function(t){t.exports=e}},n={};function r(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={exports:{}};return t[e](i,i.exports,r),i.exports}r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var o={};return function(){r.d(o,{default:function(){return d}});var e=r(771),t=r.n(e);const n=function(e,t,n){let r=n,o=0;const i=e.length;for(;r<t.length;){const n=t[r];if(o<=0&&t.slice(r,r+i)===e)return r;"\\"===n?r++:"{"===n?o++:"}"===n&&o--,r++}return-1},i=/^\\begin{/;var a=function(e,t){let r;const o=[],a=new RegExp("("+t.map((e=>e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"))).join("|")+")");for(;r=e.search(a),-1!==r;){r>0&&(o.push({type:"text",data:e.slice(0,r)}),e=e.slice(r));const a=t.findIndex((t=>e.startsWith(t.left)));if(r=n(t[a].right,e,t[a].left.length),-1===r)break;const l=e.slice(0,r+t[a].right.length),s=i.test(l)?l:e.slice(t[a].left.length,r);o.push({type:"math",data:s,rawData:l,display:t[a].display}),e=e.slice(r+t[a].right.length)}return""!==e&&o.push({type:"text",data:e}),o};const l=function(e,n){const r=a(e,n.delimiters);if(1===r.length&&"text"===r[0].type)return null;const o=document.createDocumentFragment();for(let e=0;e<r.length;e++)if("text"===r[e].type)o.appendChild(document.createTextNode(r[e].data));else{const i=document.createElement("span");let a=r[e].data;n.displayMode=r[e].display;try{n.preProcess&&(a=n.preProcess(a)),t().render(a,i,n)}catch(i){if(!(i instanceof t().ParseError))throw i;n.errorCallback("KaTeX auto-render: Failed to parse `"+r[e].data+"` with ",i),o.appendChild(document.createTextNode(r[e].rawData));continue}o.appendChild(i)}return o},s=function(e,t){for(let n=0;n<e.childNodes.length;n++){const r=e.childNodes[n];if(3===r.nodeType){let o=r.textContent,i=r.nextSibling,a=0;for(;i&&i.nodeType===Node.TEXT_NODE;)o+=i.textContent,i=i.nextSibling,a++;const s=l(o,t);if(s){for(let e=0;e<a;e++)r.nextSibling.remove();n+=s.childNodes.length-1,e.replaceChild(s,r)}else n+=a}else if(1===r.nodeType){const e=" "+r.className+" ";-1===t.ignoredTags.indexOf(r.nodeName.toLowerCase())&&t.ignoredClasses.every((t=>-1===e.indexOf(" "+t+" ")))&&s(r,t)}}};var d=function(e,t){if(!e)throw new Error("No element provided to render");const n={};for(const e in t)t.hasOwnProperty(e)&&(n[e]=t[e]);n.delimiters=n.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],n.ignoredTags=n.ignoredTags||["script","noscript","style","textarea","pre","code","option"],n.ignoredClasses=n.ignoredClasses||[],n.errorCallback=n.errorCallback||console.error,n.macros=n.macros||{},s(e,n)}}(),o=o.default}()}));
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
|
||||
+1
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&",">":">","<":"<",'"':""","'":"'"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
|
||||
@@ -2,6 +2,9 @@
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Chat · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
||||
<link rel="stylesheet" href="/_static/katex/katex.min.css">
|
||||
<script defer src="/_static/katex/katex.min.js"></script>
|
||||
<script defer src="/_static/katex/auto-render.min.js"></script>
|
||||
<meta name="csrf" content="{{.CSRF}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
@@ -69,6 +72,36 @@ access to it.</p>
|
||||
return el;
|
||||
}
|
||||
|
||||
// Maths is rendered after the text is already in place as text.
|
||||
//
|
||||
// That order is the whole safety argument. renderMathInElement walks the text
|
||||
// nodes of an element whose content was set with textContent, so nothing the
|
||||
// model wrote is ever parsed as HTML — only the spans KaTeX recognises become
|
||||
// markup, and that markup is KaTeX's, not the model's.
|
||||
//
|
||||
// trust stays false, which is KaTeX's default and worth stating: it is what
|
||||
// keeps \href, \url and \includegraphics out of a reply.
|
||||
function renderMath(el) {
|
||||
if (typeof renderMathInElement !== "function") return;
|
||||
try {
|
||||
renderMathInElement(el, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "\\[", right: "\\]", display: true },
|
||||
{ left: "\\(", right: "\\)", display: false },
|
||||
// Single $ last: a model writing about prices produces "$5 and $10",
|
||||
// and treating that as maths turns a sentence into gibberish. It is
|
||||
// still enabled because models overwhelmingly use it for maths, and
|
||||
// ignoreOnError leaves anything that does not parse as plain text.
|
||||
{ left: "$", right: "$", display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
errorColor: "var(--bad)",
|
||||
trust: false,
|
||||
});
|
||||
} catch (e) { /* a reply without maths is better than no reply */ }
|
||||
}
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
@@ -117,7 +150,12 @@ access to it.</p>
|
||||
} catch (_) { /* a partial frame; the next read completes it */ }
|
||||
}
|
||||
}
|
||||
if (reply) messages.push({ role: "assistant", content: reply });
|
||||
if (reply) {
|
||||
messages.push({ role: "assistant", content: reply });
|
||||
// Once, at the end. Re-rendering every chunk would try to typeset half
|
||||
// an expression on each token and flicker its way through the reply.
|
||||
renderMath(out);
|
||||
}
|
||||
else { out.textContent = "(no content came back)"; out.classList.add("failed"); }
|
||||
} catch (err) {
|
||||
out.textContent = "(" + err + ")";
|
||||
|
||||
+45
-14
@@ -17,6 +17,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -27,6 +28,13 @@ import (
|
||||
//go:embed templates
|
||||
var files embed.FS
|
||||
|
||||
// staticFiles is a separate embed from the templates on purpose. Static() serves
|
||||
// this whole tree, and a tree that also held the templates would serve those
|
||||
// too — harmless today and the kind of thing that stops being harmless.
|
||||
//
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"join": func(v []string) string {
|
||||
if len(v) == 0 {
|
||||
@@ -71,28 +79,51 @@ var funcs = template.FuncMap{
|
||||
|
||||
var tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(files, "templates/*.html"))
|
||||
|
||||
// Static serves the stylesheet, mounted on both listeners because both pages
|
||||
// use it and it contains nothing.
|
||||
// Static serves the embedded assets, mounted on both listeners because both
|
||||
// pages use them.
|
||||
//
|
||||
// One file rather than a directory server: exposing a file server over the
|
||||
// embedded templates would also serve the templates themselves, which is
|
||||
// harmless today and the kind of thing that stops being harmless later.
|
||||
// Over its own embed, which holds nothing but assets. A file server pointed at
|
||||
// the tree that also carries the templates would serve those as well.
|
||||
func Static() http.Handler {
|
||||
css, err := files.ReadFile("templates/base.css")
|
||||
sub, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
panic("web: the stylesheet is missing from the binary: " + err.Error())
|
||||
panic("web: the static assets are missing from the binary: " + err.Error())
|
||||
}
|
||||
fileServer := http.StripPrefix("/_static/", http.FileServerFS(noDirs{sub}))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/_static/base.css" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_, _ = w.Write(css)
|
||||
// Everything here is embedded, so it changes only when the binary does
|
||||
// and can be cached hard. The fonts in particular are 300 KB that
|
||||
// should be fetched once.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// noDirs hides directories from the file server.
|
||||
//
|
||||
// Listing them would publish the shape of the asset tree, which is not secret
|
||||
// and not anybody's business either. Nothing on any page links to a directory,
|
||||
// so the only requests for one are from somebody looking around.
|
||||
type noDirs struct{ fs.FS }
|
||||
|
||||
func (n noDirs) Open(name string) (fs.File, error) {
|
||||
f, err := n.FS.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = f.Close()
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
|
||||
@@ -226,15 +226,26 @@ func (emptySource) Models() []adminapi.ModelView { return nil }
|
||||
func (emptySource) Replicas(string) []adminapi.ReplicaView { return nil }
|
||||
func (emptySource) Explain(string) adminapi.ExplainView { return adminapi.ExplainView{} }
|
||||
|
||||
func TestStaticIsNotAFileServer(t *testing.T) {
|
||||
// A file server over the embedded templates would also serve the templates,
|
||||
// which is harmless today and the kind of thing that stops being harmless.
|
||||
func TestStaticServesAssetsAndNothingElse(t *testing.T) {
|
||||
// The assets live in their own embed. A file server over the tree that also
|
||||
// carries the templates would serve those as well, and a directory listing
|
||||
// would publish the shape of the tree to nobody's benefit.
|
||||
h := Static()
|
||||
if code, _ := get(t, h, "http://x/_static/base.css", nil); code != http.StatusOK {
|
||||
t.Errorf("the stylesheet is not served: %d", code)
|
||||
for _, path := range []string{
|
||||
"/_static/base.css",
|
||||
"/_static/katex/katex.min.css",
|
||||
"/_static/katex/katex.min.js",
|
||||
"/_static/katex/auto-render.min.js",
|
||||
"/_static/katex/fonts/KaTeX_Main-Regular.woff2",
|
||||
} {
|
||||
if code, _ := get(t, h, "http://x"+path, nil); code != http.StatusOK {
|
||||
t.Errorf("%s is not served: %d", path, code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/_static/dashboard.html", "/_static/", "/_static/../web.go",
|
||||
"/_static/dashboard.html", "/_static/chat.html",
|
||||
"/_static/", "/_static/katex/", "/_static/katex/fonts/",
|
||||
"/_static/../web.go",
|
||||
} {
|
||||
if code, _ := get(t, h, "http://x"+path, nil); code == http.StatusOK {
|
||||
t.Errorf("%s was served", path)
|
||||
|
||||
Reference in new issue
Block a user