From 6bbb21a5ba5dc1c40e61f5860d0f081ebf2d3bb6 Mon Sep 17 00:00:00 2001 From: barrulus Date: Wed, 2 Sep 2026 00:12:40 +0100 Subject: [PATCH 01/37] feat: escaped markdown renderer for the help assistant --- src/utils/markdown.test.ts | 85 ++++++++++++++++++ src/utils/markdown.ts | 171 +++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/utils/markdown.test.ts create mode 100644 src/utils/markdown.ts diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts new file mode 100644 index 0000000000..59fcc09754 --- /dev/null +++ b/src/utils/markdown.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { renderMarkdown } from "./markdown"; + +describe("renderMarkdown", () => { + it("wraps plain text in a paragraph and keeps single newlines as breaks", () => { + expect(renderMarkdown("first\nsecond")).toBe("

first
second

"); + }); + + it("renders headings shifted down so they fit inside a chat bubble", () => { + expect(renderMarkdown("# Title")).toBe("

Title

"); + expect(renderMarkdown("### Deep")).toBe("
Deep
"); + }); + + it("renders inline marks", () => { + expect(renderMarkdown("**bold** and *slanted* and ~~gone~~")).toBe( + "

bold and slanted and gone

" + ); + }); + + it("renders code spans without parsing their contents", () => { + expect(renderMarkdown("use `pack.burgs[0]` and `a *b* c`")).toBe( + "

use pack.burgs[0] and a *b* c

" + ); + }); + + it("does not mistake a bare number for a code-span placeholder", () => { + expect(renderMarkdown("`x` and 0 and 1")).toBe("

x and 0 and 1

"); + }); + + it("renders fenced code blocks", () => { + expect(renderMarkdown("```js\nreturn 1 < 2;\n```")).toBe("
return 1 < 2;
"); + }); + + it("renders unordered and ordered lists", () => { + expect(renderMarkdown("- one\n- two")).toBe(""); + expect(renderMarkdown("1. one\n2. two")).toBe("
  1. one
  2. two
"); + }); + + it("nests a child list inside its parent item", () => { + expect(renderMarkdown("- parent\n - child\n- sibling")).toBe( + "" + ); + }); + + it("renders tables with alignment", () => { + const table = "| State | Burgs |\n| --- | ---: |\n| Kelmora | 12 |"; + expect(renderMarkdown(table)).toBe( + '' + + '
StateBurgs
Kelmora12
' + ); + }); + + it("renders blockquotes and rules", () => { + expect(renderMarkdown("> quoted")).toBe("

quoted

"); + expect(renderMarkdown("---")).toBe("
"); + }); + + it("links only http and https targets", () => { + expect(renderMarkdown("[wiki](https://example.com/a)")).toBe( + '

wiki

' + ); + expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("

[bad](javascript:alert(1))

"); + }); + + it("escapes markup so model output cannot inject HTML", () => { + expect(renderMarkdown('')).toBe( + "

<img src=x onerror="alert(1)">

" + ); + expect(renderMarkdown("a & b")).toBe("

a & b

"); + }); + + it("escapes inside code spans and blocks too", () => { + expect(renderMarkdown("``")).toBe("

<b>

"); + }); + + it("handles an unterminated fence without hanging", () => { + expect(renderMarkdown("```\nunclosed")).toBe("
unclosed
"); + }); + + it("keeps blocks separate", () => { + expect(renderMarkdown("## Ports\n\n- Kelmora\n\nDone.")).toBe( + "

Ports

Done.

" + ); + }); +}); diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 0000000000..a274d4ff91 --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,171 @@ +// A small Markdown-to-HTML renderer for chat answers — headings, lists, tables, code, links and the +// common inline marks. Not a CommonMark implementation: it covers what a model writes in an answer, +// with no dependency and no HTML passthrough (every leaf is escaped, so model output cannot inject +// markup). + +interface ListItem { + indent: number; + ordered: boolean; + text: string; +} + +const FENCE = /^ {0,3}```(\S*)\s*$/; +const HEADING = /^ {0,3}(#{1,6})\s+(.*)$/; +const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/; +const QUOTE = /^ {0,3}>\s?(.*)$/; +const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/; +const TABLE_DIVIDER = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/; + +// Code spans are parked behind a NUL-delimited marker: it survives escaping and can never appear +// in model output or in a tag we generate +const PLACEHOLDER = String.fromCharCode(0); +const PLACEHOLDER_PATTERN = new RegExp(`${PLACEHOLDER}(\\d+)${PLACEHOLDER}`, "g"); + +export function renderMarkdown(source: string): string { + const lines = source.replace(/\r\n?/g, "\n").split("\n"); + const html: string[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + + if (!line.trim()) { + index++; + continue; + } + + if (FENCE.test(line)) { + const code: string[] = []; + index++; + while (index < lines.length && !FENCE.test(lines[index])) code.push(lines[index++]); + index++; // closing fence, or the end of the input + html.push(`
${escapeHtml(code.join("\n"))}
`); + continue; + } + + const heading = line.match(HEADING); + if (heading) { + // a document h1 is far too loud inside a chat bubble, so the whole scale is shifted down + const level = Math.min(heading[1].length + 2, 6); + html.push(`${inline(heading[2])}`); + index++; + continue; + } + + if (RULE.test(line)) { + html.push("
"); + index++; + continue; + } + + if (QUOTE.test(line)) { + const quoted: string[] = []; + while (index < lines.length && QUOTE.test(lines[index])) quoted.push(lines[index++].match(QUOTE)?.[1] ?? ""); + html.push(`
${renderMarkdown(quoted.join("\n"))}
`); + continue; + } + + if (LIST_ITEM.test(line)) { + const items: ListItem[] = []; + while (index < lines.length) { + const match = lines[index].match(LIST_ITEM); + if (!match) break; + items.push({ indent: match[1].length, ordered: /\d/.test(match[2]), text: match[3] }); + index++; + } + html.push(buildList(items, 0).html); + continue; + } + + if (line.includes("|") && index + 1 < lines.length && TABLE_DIVIDER.test(lines[index + 1])) { + index = buildTable(lines, index, html); + continue; + } + + const paragraph: string[] = []; + while (index < lines.length && lines[index].trim() && !startsBlock(lines[index])) paragraph.push(lines[index++]); + html.push(`

${paragraph.map(inline).join("
")}

`); + } + + return html.join(""); +} + +function startsBlock(line: string): boolean { + return FENCE.test(line) || HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || LIST_ITEM.test(line); +} + +// Nested lists are built by recursion so that a child list stays inside its parent's
  • +function buildList(items: ListItem[], start: number): { html: string; next: number } { + const { indent, ordered } = items[start]; + const contents: string[] = []; + let index = start; + + while (index < items.length && items[index].indent >= indent) { + if (items[index].indent > indent && contents.length) { + const nested = buildList(items, index); + contents[contents.length - 1] += nested.html; + index = nested.next; + continue; + } + contents.push(inline(items[index].text)); + index++; + } + + const tag = ordered ? "ol" : "ul"; + return { html: `<${tag}>${contents.map(content => `
  • ${content}
  • `).join("")}`, next: index }; +} + +function buildTable(lines: string[], start: number, html: string[]): number { + const headers = splitRow(lines[start]); + const alignments = splitRow(lines[start + 1]).map(cell => { + if (cell.startsWith(":") && cell.endsWith(":")) return ' style="text-align: center"'; + if (cell.endsWith(":")) return ' style="text-align: right"'; + return ""; + }); + + const cell = (content: string, column: number, tag: "th" | "td"): string => + `<${tag}${alignments[column] ?? ""}>${inline(content)}`; + + const rows: string[] = []; + let index = start + 2; + while (index < lines.length && lines[index].includes("|")) { + const values = splitRow(lines[index]); + rows.push(`${values.map((value, column) => cell(value, column, "td")).join("")}`); + index++; + } + + const head = headers.map((header, column) => cell(header, column, "th")).join(""); + html.push(`${head}${rows.join("")}
    `); + return index; +} + +const splitRow = (line: string): string[] => + line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map(value => value.trim()); + +// Inline marks. Code spans are pulled out first so that their contents are never re-parsed, then +// everything else is escaped before any tag of ours is introduced. +function inline(text: string): string { + const codes: string[] = []; + const withPlaceholders = text.replace(/`([^`]+)`/g, (_, code: string) => { + codes.push(`${escapeHtml(code)}`); + return `${PLACEHOLDER}${codes.length - 1}${PLACEHOLDER}`; + }); + + const marked = escapeHtml(withPlaceholders) + .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label: string, href: string) => + /^https?:\/\//i.test(href) ? `${label}` : whole + ) + .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2") + .replace(/\*(?=\S)([^*\n]*\S)\*/g, "$1") + .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1"); + + return marked.replace(PLACEHOLDER_PATTERN, (_, id: string) => codes[Number(id)]); +} + +const escapeHtml = (text: string): string => + text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); From 8832425e73e5ff1cca9b14230deac33c572078dd Mon Sep 17 00:00:00 2001 From: barrulus Date: Wed, 2 Sep 2026 00:16:17 +0100 Subject: [PATCH 02/37] feat: help gateway API client --- src/services/help/api.test.ts | 99 +++++++++++++++++++++++++++++++++++ src/services/help/api.ts | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/services/help/api.test.ts create mode 100644 src/services/help/api.ts diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts new file mode 100644 index 0000000000..1a70a51167 --- /dev/null +++ b/src/services/help/api.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ask, GATEWAY_URL, getLimits, HelpApiError, OFFICIAL_ORIGIN } from "./api"; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +afterEach(() => vi.unstubAllGlobals()); + +describe("constants", () => { + it("pins the gateway base URL with no trailing slash", () => { + expect(GATEWAY_URL).toBe("https://ask.azgaarsfmg.com"); + expect(GATEWAY_URL.endsWith("/")).toBe(false); + }); + + it("pins the official origin as scheme + host only", () => { + expect(OFFICIAL_ORIGIN).toBe("https://azgaar.github.io"); + expect(OFFICIAL_ORIGIN.endsWith("/")).toBe(false); + expect(new URL(OFFICIAL_ORIGIN).pathname).toBe("/"); + }); +}); + +describe("ask", () => { + it("POSTs exactly {question} to /v1/ask and returns the parsed answer", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, { requestId: 4711, answer: "**hi**", model: "m", usage: { prompt: 1 } })); + vi.stubGlobal("fetch", fetchMock); + + const result = await ask("How do I export SVG?"); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${GATEWAY_URL}/v1/ask`); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ question: "How do I export SVG?" }); + expect(Object.keys(body)).toEqual(["question"]); + expect(result.requestId).toBe(4711); + expect(result.answer).toBe("**hi**"); + }); + + it("parses a refusal 200 (nullable requestId/model/usage) as a normal answer", async () => { + const refusal = { requestId: null, answer: "I can't help with topics unrelated to FMG.", model: null, usage: null }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, refusal))); + + const result = await ask("recipe for baked potatoes"); + + expect(result.requestId).toBeNull(); + expect(result.answer).toBe("I can't help with topics unrelated to FMG."); + }); + + it.each([ + ["rate_limited", 429, 10], + ["quota", 429, undefined], + ["cap_reached", 403, undefined], + ["blocked", 403, undefined], + ["provider_error", 502, undefined], + ["invalid_request", 400, undefined] + ])("maps a %s error body to HelpApiError with verbatim message", async (code, status, retryAfter) => { + const errorBody = { error: { code, message: `server text for ${code}`, ...(retryAfter ? { retryAfter } : {}) } }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(status, errorBody))); + + const error = await ask("q").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HelpApiError); + expect((error as HelpApiError).code).toBe(code); + expect((error as HelpApiError).message).toBe(`server text for ${code}`); + expect((error as HelpApiError).retryAfter).toBe(retryAfter); + }); + + it("maps a non-2xx with an unparseable body to provider_error", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad gateway", { status: 502 }))); + const error = await ask("q").catch((e: unknown) => e); + expect((error as HelpApiError).code).toBe("provider_error"); + }); + + it("maps a network failure to unreachable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + const error = await ask("q").catch((e: unknown) => e); + expect((error as HelpApiError).code).toBe("unreachable"); + }); +}); + +describe("getLimits", () => { + it("GETs /v1/limits and returns the parsed limits", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, { tier: "anonymous", remaining: 3, resetsAt: "2026-09-03T00:00:00Z" })); + vi.stubGlobal("fetch", fetchMock); + + const limits = await getLimits(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${GATEWAY_URL}/v1/limits`); + expect(init.method).toBe("GET"); + expect(limits.remaining).toBe(3); + }); +}); diff --git a/src/services/help/api.ts b/src/services/help/api.ts new file mode 100644 index 0000000000..6b70342797 --- /dev/null +++ b/src/services/help/api.ts @@ -0,0 +1,93 @@ +// Client for the fmg-bot help gateway (server spec: azgaar/fmg-bot +// docs/superpowers/specs/2026-09-01-web-help-endpoint-design.md). Everything is server-pinned; +// the request body is exactly {question} by contract — unknown fields are a 400. + +export const GATEWAY_URL = "https://ask.azgaarsfmg.com"; + +// Scheme + host only — the origin the gateway allows, NOT where requests go +export const OFFICIAL_ORIGIN = "https://azgaar.github.io"; + +// On a 200 only `answer` is reliable (always a non-empty string). A refusal or empty +// model reply is a normal 200 with requestId/model/usage null — never an error state. +// requestId (number when present) is slice 3's feedback handle; null = nothing to rate. +export interface AskResponse { + requestId: number | null; + answer: string; + model: string | null; + usage: Record | null; +} + +export interface Limits { + tier: "anonymous" | "member" | "moderator"; + remaining: number; + resetsAt: string; +} + +export type HelpErrorCode = + | "rate_limited" + | "quota" + | "cap_reached" + | "blocked" + | "provider_error" + | "invalid_request" + | "unreachable"; + +export class HelpApiError extends Error { + code: HelpErrorCode; + retryAfter?: number; + + constructor(code: HelpErrorCode, message: string, retryAfter?: number) { + super(message); + this.name = "HelpApiError"; + this.code = code; + this.retryAfter = retryAfter; + } +} + +// Dev-only escape hatch so the local stub can stand in for the gateway +function gatewayBase(): string { + if (import.meta.env.DEV) { + try { + const override = localStorage.getItem("fmg-help-gateway"); + if (override) return override.replace(/\/+$/, ""); + } catch { + // storage unavailable — fall through to the real gateway + } + } + return GATEWAY_URL; +} + +async function request(path: string, init: RequestInit): Promise { + let response: Response; + try { + response = await fetch(`${gatewayBase()}${path}`, init); + } catch { + throw new HelpApiError("unreachable", "The assistant is unreachable. Check your connection and try again."); + } + + if (response.ok) return response.json() as Promise; + + let code: HelpErrorCode = "provider_error"; + let message = `The assistant returned an error (${response.status}).`; + let retryAfter: number | undefined; + try { + const body = await response.json(); + if (body?.error) { + code = body.error.code ?? code; + message = body.error.message ?? message; + retryAfter = body.error.retryAfter; + } + } catch { + // non-JSON error body — keep the generic provider_error + } + throw new HelpApiError(code, message, retryAfter); +} + +export const ask = (question: string): Promise => + request("/v1/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ question }) + }); + +export const getLimits = (): Promise => request("/v1/limits", { method: "GET" }); From e18ed8ad69adeebbed2f821b20df62bb0ed0f930 Mon Sep 17 00:00:00 2001 From: barrulus Date: Wed, 2 Sep 2026 00:18:53 +0100 Subject: [PATCH 03/37] chore: align help API comments with revised gateway contract --- src/services/help/api.test.ts | 2 +- src/services/help/api.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts index 1a70a51167..d8e2696d61 100644 --- a/src/services/help/api.test.ts +++ b/src/services/help/api.test.ts @@ -40,7 +40,7 @@ describe("ask", () => { expect(result.answer).toBe("**hi**"); }); - it("parses a refusal 200 (nullable requestId/model/usage) as a normal answer", async () => { + it("tolerates nullable requestId/model/usage on a 200 (contract allows null)", async () => { const refusal = { requestId: null, answer: "I can't help with topics unrelated to FMG.", model: null, usage: null }; vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, refusal))); diff --git a/src/services/help/api.ts b/src/services/help/api.ts index 6b70342797..3b8b125007 100644 --- a/src/services/help/api.ts +++ b/src/services/help/api.ts @@ -7,9 +7,10 @@ export const GATEWAY_URL = "https://ask.azgaarsfmg.com"; // Scheme + host only — the origin the gateway allows, NOT where requests go export const OFFICIAL_ORIGIN = "https://azgaar.github.io"; -// On a 200 only `answer` is reliable (always a non-empty string). A refusal or empty -// model reply is a normal 200 with requestId/model/usage null — never an error state. -// requestId (number when present) is slice 3's feedback handle; null = nothing to rate. +// On a 200 only `answer` is reliable (always a non-empty string); the other fields are +// nullable, though in practice requestId is always a number — refusals are ledgered and +// rateable. A refusal or empty reply is a normal 200, never an error state. requestId is +// slice 3's feedback handle; hide feedback only when it is actually null. export interface AskResponse { requestId: number | null; answer: string; From b3dba81c5069773343c08d29de49eddac8ee53ad Mon Sep 17 00:00:00 2001 From: barrulus Date: Wed, 2 Sep 2026 00:26:16 +0100 Subject: [PATCH 04/37] feat: help assistant declined-state and limits helpers --- src/controllers/help-assistant.test.ts | 63 ++++++++++++++++++++++++++ src/controllers/help-assistant.ts | 43 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/controllers/help-assistant.test.ts create mode 100644 src/controllers/help-assistant.ts diff --git a/src/controllers/help-assistant.test.ts b/src/controllers/help-assistant.test.ts new file mode 100644 index 0000000000..93f30bac88 --- /dev/null +++ b/src/controllers/help-assistant.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { HelpApiError } from "@/services/help/api"; +import { renderMarkdown } from "@/utils/markdown"; +import { limitsLabel, normalizeQuestion, noticeFor } from "./help-assistant"; + +describe("noticeFor", () => { + // Budget-refusal text is the server's to write (it carries wiki/Discord links as live + // markdown); the client must render it verbatim with nothing added. + it.each([ + "cap_reached", + "quota", + "blocked" + ] as const)("renders %s server text verbatim as escaped markdown and disables asking", code => { + const message = "Budget used — see the [documentation](https://github.com/Azgaar/Fantasy-Map-Generator/wiki)."; + const notice = noticeFor(new HelpApiError(code, message)); + expect(notice.html).toBe(renderMarkdown(message)); + expect(notice.askDisabled).toBe(true); + expect(notice.retryCountdown).toBeUndefined(); + }); + + it("gives rate_limited a countdown from retryAfter", () => { + const notice = noticeFor(new HelpApiError("rate_limited", "Slow down.", 12)); + expect(notice.askDisabled).toBe(true); + expect(notice.retryCountdown).toBe(12); + }); + + it("defaults the rate_limited countdown to 30 when retryAfter is missing", () => { + expect(noticeFor(new HelpApiError("rate_limited", "Slow down.")).retryCountdown).toBe(30); + }); + + it("leaves asking enabled for provider_error and unreachable", () => { + expect(noticeFor(new HelpApiError("provider_error", "oops")).askDisabled).toBe(false); + expect(noticeFor(new HelpApiError("unreachable", "no net")).askDisabled).toBe(false); + }); + + it("escapes hostile markup in server messages", () => { + const notice = noticeFor(new HelpApiError("provider_error", '')); + expect(notice.html).not.toContain(" { + const limits = (remaining: number) => ({ tier: "anonymous" as const, remaining, resetsAt: "2026-09-03T00:00:00Z" }); + it("pluralizes remaining questions", () => { + expect(limitsLabel(limits(5))).toBe("5 questions left today"); + expect(limitsLabel(limits(1))).toBe("1 question left today"); + expect(limitsLabel(limits(0))).toBe("No questions left today"); + }); +}); + +describe("normalizeQuestion", () => { + it("trims and accepts 1 to 1000 characters", () => { + expect(normalizeQuestion(" how? ")).toBe("how?"); + expect(normalizeQuestion("a".repeat(1000))).toBe("a".repeat(1000)); + }); + it("rejects empty, whitespace-only, and overlong input", () => { + expect(normalizeQuestion("")).toBeNull(); + expect(normalizeQuestion(" \n ")).toBeNull(); + expect(normalizeQuestion("a".repeat(1001))).toBeNull(); + }); +}); diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts new file mode 100644 index 0000000000..9bb406e01b --- /dev/null +++ b/src/controllers/help-assistant.ts @@ -0,0 +1,43 @@ +// First-party help assistant: asks the fmg-bot gateway one question at a time and renders the +// answer as escaped markdown. Replaces the OpenWidget bubble. Client design spec: +// docs/superpowers/specs/2026-09-01-help-box-client-design.md (fork repo). + +import type { HelpApiError, Limits } from "@/services/help/api"; +import { renderMarkdown } from "@/utils/markdown"; + +export interface WidgetNotice { + html: string; + askDisabled: boolean; + retryCountdown?: number; +} + +const DEFAULT_RETRY_SECONDS = 30; + +// Declined states are designed states: the budget/quota text arrives display-ready from the +// server (with live links) and is rendered verbatim — never composed here. +export function noticeFor(error: HelpApiError): WidgetNotice { + const html = renderMarkdown(error.message); + switch (error.code) { + case "cap_reached": + case "quota": + case "blocked": + return { html, askDisabled: true }; + case "rate_limited": + return { html, askDisabled: true, retryCountdown: error.retryAfter ?? DEFAULT_RETRY_SECONDS }; + default: + return { html, askDisabled: false }; + } +} + +export function limitsLabel(limits: Limits): string { + if (limits.remaining <= 0) return "No questions left today"; + return `${limits.remaining} question${limits.remaining === 1 ? "" : "s"} left today`; +} + +const MAX_QUESTION_LENGTH = 1000; + +export function normalizeQuestion(raw: string): string | null { + const question = raw.trim(); + if (!question.length || question.length > MAX_QUESTION_LENGTH) return null; + return question; +} From fed5caf5fca2cd618196d9c70268019c75f9a79a Mon Sep 17 00:00:00 2001 From: barrulus Date: Wed, 2 Sep 2026 00:30:52 +0100 Subject: [PATCH 05/37] feat: help assistant dialog wired to the gateway --- src/controllers/help-assistant.ts | 159 +++++++++++++++++++++++++++++- src/controllers/index.ts | 1 + 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts index 9bb406e01b..acc3b835f3 100644 --- a/src/controllers/help-assistant.ts +++ b/src/controllers/help-assistant.ts @@ -2,8 +2,11 @@ // answer as escaped markdown. Replaces the OpenWidget bubble. Client design spec: // docs/superpowers/specs/2026-09-01-help-box-client-design.md (fork repo). -import type { HelpApiError, Limits } from "@/services/help/api"; +import { destroyDialog } from "@/components/dialog/dialog-helpers"; +import type { Limits } from "@/services/help/api"; +import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN } from "@/services/help/api"; import { renderMarkdown } from "@/utils/markdown"; +import { ensureEl } from "../utils"; export interface WidgetNotice { html: string; @@ -41,3 +44,157 @@ export function normalizeQuestion(raw: string): string | null { if (!question.length || question.length > MAX_QUESTION_LENGTH) return null; return question; } + +const isOfficialOrigin = (): boolean => location.origin === OFFICIAL_ORIGIN || import.meta.env.DEV; + +function open(): void { + renderDialog(); + + $("#helpAssistant").dialog({ + title: "Azgaar's Assistant", + position: { my: "center", at: "center", of: "svg" }, + resizable: false, + close: () => destroyDialog("helpAssistant") + }); + + if (isOfficialOrigin()) void refreshLimits(); +} + +function renderDialog(): void { + destroyDialog("helpAssistant"); + + const form = /* html */ ` +
    +

    Ask anything about using the Fantasy Map Generator.

    +
    + + +
    + + +
    +
    Questions are kept for 90 days to help improve the documentation.
    `; + + // Self-hosted copies are not on the gateway's origin allowlist: explain, don't error + const unlisted = /* html */ ` +
    +

    The free assistant is only available on the official site: + + azgaar.github.io/Fantasy-Map-Generator.

    +

    On a self-hosted copy, the + documentation + covers most questions.

    +
    `; + + const html = /* html */ `
    + ${isOfficialOrigin() ? form : unlisted} +
    `; + ensureEl("dialogs").insertAdjacentHTML("beforeend", html); + + if (!isOfficialOrigin()) return; + ensureEl("helpAssistantAsk").addEventListener("click", () => void submit()); + ensureEl("helpAssistantQuestion").addEventListener("keydown", event => { + if ( + (event as KeyboardEvent).key === "Enter" && + ((event as KeyboardEvent).ctrlKey || (event as KeyboardEvent).metaKey) + ) { + void submit(); + } + }); +} + +async function submit(): Promise { + const textarea = ensureEl("helpAssistantQuestion"); + const question = normalizeQuestion(textarea.value); + if (!question) return; + + const button = ensureEl("helpAssistantAsk"); + button.disabled = true; + button.textContent = "Asking…"; + appendEntry("helpAssistantAsked", question); + + try { + const { answer } = await ask(question); + appendAnswer(renderMarkdown(answer)); + textarea.value = ""; + setNotice(null); + } catch (error) { + if (error instanceof HelpApiError) applyNotice(noticeFor(error)); + else throw error; + } finally { + if (!button.dataset.locked) { + button.disabled = false; + button.textContent = "Ask"; + } + void refreshLimits(); + } +} + +// The question is the user's own text: insert via textContent, never as markup +function appendEntry(className: string, text: string): void { + const entry = document.createElement("p"); + entry.className = className; + entry.textContent = text; + appendToLog(entry); +} + +// renderMarkdown output only — the renderer escapes every leaf +function appendAnswer(safeHtml: string): void { + const entry = document.createElement("div"); + entry.className = "helpAssistantAnswer"; + entry.innerHTML = safeHtml; + appendToLog(entry); +} + +function appendToLog(node: HTMLElement): void { + const log = ensureEl("helpAssistantLog"); + log.appendChild(node); + log.scrollTop = log.scrollHeight; +} + +function setNotice(safeHtml: string | null): void { + const notice = ensureEl("helpAssistantNotice"); + notice.hidden = safeHtml === null; + notice.innerHTML = safeHtml ?? ""; +} + +let retryTimer: ReturnType | null = null; + +function applyNotice(notice: WidgetNotice): void { + setNotice(notice.html); + const button = ensureEl("helpAssistantAsk"); + if (retryTimer) clearInterval(retryTimer); + + if (!notice.askDisabled) return; + button.disabled = true; + button.dataset.locked = "true"; + + if (notice.retryCountdown === undefined) return; // quota/cap/blocked: stays disabled + let secondsLeft = notice.retryCountdown; + button.textContent = `Wait ${secondsLeft}s`; + retryTimer = setInterval(() => { + secondsLeft -= 1; + if (secondsLeft > 0) { + button.textContent = `Wait ${secondsLeft}s`; + return; + } + if (retryTimer) clearInterval(retryTimer); + retryTimer = null; + delete button.dataset.locked; + button.disabled = false; + button.textContent = "Ask"; + setNotice(null); + }, 1000); +} + +async function refreshLimits(): Promise { + try { + const limits = await getLimits(); + ensureEl("helpAssistantLimits").textContent = limitsLabel(limits); + } catch { + // limits are a nicety; asking still reports the authoritative state + } +} + +export const HelpAssistant = { open }; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 4e7a116257..6486fb0832 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -23,6 +23,7 @@ export const Controllers = createRegistry({ GoodsEditor: () => import("@/controllers/goods-editor").then(m => m.GoodsEditor), HeightmapEditor: () => import("@/controllers/heightmap-editor").then(m => m.HeightmapEditor), HeightmapSelection: () => import("@/controllers/heightmap-selection").then(m => m.HeightmapSelection), + HelpAssistant: () => import("@/controllers/help-assistant").then(m => m.HelpAssistant), IconSelector: () => import("@/controllers/icon-selector").then(m => m.IconSelector), HierarchyTree: () => import("@/controllers/hierarchy-tree").then(m => m.HierarchyTree), IceEditor: () => import("@/controllers/ice-editor").then(m => m.IceEditor), From 88c62747c5a3a1a17726f4176e8bff18f02dab74 Mon Sep 17 00:00:00 2001 From: barrulus Date: Wed, 2 Sep 2026 00:34:18 +0100 Subject: [PATCH 06/37] feat: first-party assistant bubble replaces OpenWidget --- electron/main.ts | 11 +++----- public/index.css | 47 +++++++++++++++++++++++++++++++++++ public/libs/openwidget.min.js | 1 - public/main.js | 25 +++---------------- src/index.html | 1 + 5 files changed, 56 insertions(+), 29 deletions(-) delete mode 100644 public/libs/openwidget.min.js diff --git a/electron/main.ts b/electron/main.ts index 4148507707..27d76cb41e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -85,16 +85,13 @@ protocol.registerSchemesAsPrivileged([ /** * A .map file is shared like a document, and the app builds markup out of what is inside it, so the one - * directive that matters is `script-src`: no origin but the build itself may supply code, save for the - * Assistant widget the user opts into. The rest stays permissive, because maps embed data/blob images and - * fonts and the AI providers are fetched over https. `unsafe-eval` is required by the goods distribution - * formulas, which compile to `new Function` + * directive that matters is `script-src`: no external origin may supply code. The rest stays permissive, + * because maps embed data/blob images and fonts and the AI providers are fetched over https. `unsafe-eval` + * is required by the goods distribution formulas, which compile to `new Function` */ -const ASSISTANT_ORIGINS = "https://*.openwidget.com"; - const CSP = [ "default-src 'self' data: blob:", - `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${ASSISTANT_ORIGINS}`, + `script-src 'self' 'unsafe-inline' 'unsafe-eval'`, "style-src 'self' 'unsafe-inline' https:", "font-src 'self' data: https:", "img-src 'self' data: blob: https:", diff --git a/public/index.css b/public/index.css index 901939867e..34bf29fc25 100644 --- a/public/index.css +++ b/public/index.css @@ -2642,3 +2642,50 @@ body.tour-free-roam * { gap: 0.5em; margin: 0.8em 0 0.4em 0; } + +#helpAssistantBubble { + position: fixed; + right: 16px; + bottom: 16px; + width: 44px; + height: 44px; + border-radius: 50%; + background: #35424d; + color: #fff; + font-size: 1.4em; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 6px rgb(0 0 0 / 35%); + z-index: 99; +} + +#helpAssistant .helpAssistantLog { + max-height: 40vh; + min-height: 8em; + overflow-y: auto; + margin-bottom: 0.5em; +} + +#helpAssistant .helpAssistantAsked { + font-style: italic; + opacity: 0.8; +} + +#helpAssistant .helpAssistantFooter { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.3em; +} + +#helpAssistant .helpAssistantDisclosure { + font-size: 0.85em; + opacity: 0.7; + margin-top: 0.5em; +} + +#helpAssistant textarea { + width: 100%; + box-sizing: border-box; +} diff --git a/public/libs/openwidget.min.js b/public/libs/openwidget.min.js deleted file mode 100644 index 89583d8ffb..0000000000 --- a/public/libs/openwidget.min.js +++ /dev/null @@ -1 +0,0 @@ -window.__ow=window.__ow||{},window.__ow.organizationId="7bb02e70-bcef-4861-a4e6-d259b0d10e24",window.__ow.integration_name="manual_settings",window.__ow.product_name="openwidget",function(n,e,t){function o(n){return c._h?c._h.apply(null,n):c._q.push(n)}var c={_q:[],_h:null,_v:"2.0",on:function(){o(["on",t.call(arguments)])},once:function(){o(["once",t.call(arguments)])},off:function(){o(["off",t.call(arguments)])},get:function(){if(!c._h)throw Error("[OpenWidget] You can't use getters before load.");return o(["get",t.call(arguments)])},call:function(){o(["call",t.call(arguments)])},init:function(){var n=e.createElement("script");n.async=!0,n.type="text/javascript",n.src="https://cdn.openwidget.com/openwidget.js",e.head.appendChild(n)}};n.__ow.asyncInit||c.init(),n.OpenWidget=n.OpenWidget||c}(window,document,[].slice); \ No newline at end of file diff --git a/public/main.js b/public/main.js index 7b18c812a8..562ecc92c7 100644 --- a/public/main.js +++ b/public/main.js @@ -28,6 +28,7 @@ Layers.init(); // create the svg layer groups d3.select("#scaleBar") .on("mousemove", () => tip("Click to open Units Editor")) .on("click", () => window.Controllers.UnitsEditor.open()); +document.getElementById("helpAssistantBubble")?.addEventListener("click", () => window.Controllers.HelpAssistant.open()); d3.select("#legend") .on("mousemove", () => tip("Drag to change the position. Click to hide the legend")) .on("click", () => clearLegend()); @@ -251,31 +252,13 @@ function focusOn() { } } -let isAssistantLoaded = false; function toggleAssistant() { if (window.electron) return; + const bubble = document.getElementById("helpAssistantBubble"); + if (!bubble) return; const showAssistant = document.getElementById("azgaarAssistant")?.value === "show"; - if (showAssistant) { - if (isAssistantLoaded) { - const assistantContainer = document.getElementById("chat-widget-container"); - if (assistantContainer) assistantContainer.style.display = "block"; - } else { - import("./libs/openwidget.min.js").then(() => { - isAssistantLoaded = true; - setTimeout(() => { - const bubble = document.getElementById("chat-widget-minimized"); - if (bubble) { - bubble.dataset.tip = "Click to open the Assistant"; - bubble.addEventListener("mouseover", showDataTip); - } - }, 5000); - }); - } - } else if (isAssistantLoaded) { - const assistantContainer = document.getElementById("chat-widget-container"); - if (assistantContainer) assistantContainer.style.display = "none"; - } + bubble.style.display = showAssistant ? "flex" : "none"; } function initTourPromptButton() { diff --git a/src/index.html b/src/index.html index c0a1db6812..4684e0cc39 100644 --- a/src/index.html +++ b/src/index.html @@ -2160,6 +2160,7 @@ +
    - +