Files
m3e-canvas/web/vendor/lib/ai.ts
T
pastilhas 80529dd9e7 six defects, and a typecheck the repo could not pass
Found by reviewing the plugin rather than running it. Nothing was live — the
table does not exist and the plugin is not installed — so all of these were
waiting for the first person to use it.

── The second unnamed design could not be saved ──

`uniqueIndex(userId, title)`, and every design starts life called "Untitled":
`titleOf` returns it for a document with no title and the API defaults to it. So
the first saved, the second collided, and the insert threw under a panel that
could only say "Changes are not being saved." Renaming one design to match
another did the same.

Two designs may share a name, as files and notes and dashboards do. Nothing looks
a design up by title, so uniqueness bought nothing. The index is now ordinary and
on the columns the list query actually uses.

The lesson from `dashboards.id` that the schema comment cites is that a key must
be scoped to its owner. `userId` on the row does that; making the pair unique as
well imported the collision it was citing, one account at a time.

── New did not start a new design ──

It cleared `?doc=`, and no parameter already means "open what I had last",
answered with `/documents/latest`. So New handed back the design on screen and
pointed the next save at its id. There is a `new` sentinel now, in its own module
because both panels read it and neither owns it.

── Two ways the load effect raced the debounced save ──

Changing `?doc=` does not unmount the panel, so the unmount flush never ran for
it: an edit made within the debounce and navigated away from was written after
`latest.current` had been replaced, which is to say never.

And the first save of a new design puts its id in the URL, which lands in the
same effect — re-reading the row overwrote in-memory state with the copy the
server had just been given, discarding whatever was drawn during the round trip.

The effect now flushes what is pending before it reads, and does not re-read a
document it already holds. The editor is keyed on a load generation rather than
on `row?.id`, which could not express "blank again" and left the previous design
on screen with `rowId` cleared underneath it.

── The typecheck ──

354 of the 355 errors were upstream's tree measured against this platform's
settings, which it was never written for. `bunx tsgo` is how this repo is kept
clean and it could not be clean while this plugin existed. `exclude` does not
help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file
unchecked in the same deterministic pass that rewrites its imports. Types still
resolve; only the errors inside those files are silenced, which is what the
adapter already says the seam is for. Whole repo: 0.

── PROVENANCE.md understated what is done to the copy ──

It said "the one edit vendor.sh makes" and described the import rewrite. It also
runs apply-host.py, which places our own lib/host.ts and wires three export
paths. Four files are not upstream and the document whose job is making the copy
trustworthy did not say so. It lists all four now.

Also: web/host.ts imported a path only correct at its destination, so the file
could never typecheck where it is edited — apply-host.py rewrites that line when
placing it, and re-marks the placed copy, which it overwrites after vendor.sh has
marked it. PUT is no longer full-replace: omitting `title` renamed a design to
"Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a
document is bounded at 4 MB, measured in bytes, where the title was capped at 200
and the document at nothing.
2026-09-05 19:52:27 +01:00

256 lines
12 KiB
TypeScript

// @ts-nocheck — vendored, see PROVENANCE.md
import { Doc, Frame, Group, Item, frameOfGroup } from "./tokens";
import { buildPrompt } from "./prompt";
import { isProject } from "./project";
import { Lang } from "./i18n";
/* Optional AI helpers. The browser talks to the model provider directly with the
* author's own key; there is no server in between. Every action has a fixed
* prompt and a fixed JSON answer shape, and the result is only applied after the
* author has looked at it. Coordinates are never touched by the model. */
export type Provider = "claude" | "openai" | "gemini" | "deepseek";
export type AiSettings = {
provider: Provider;
baseUrl: string;
model: string;
key: string;
};
export const PROVIDERS: { key: Provider; label: string; baseUrl: string; model: string; keysUrl?: string }[] = [
{ key: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-5.6-luna", keysUrl: "https://platform.openai.com/api-keys" },
{ key: "claude", label: "Claude", baseUrl: "https://api.anthropic.com", model: "claude-sonnet-5", keysUrl: "https://console.anthropic.com/settings/keys" },
{ key: "gemini", label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3.8-flash", keysUrl: "https://aistudio.google.com/apikey" },
{ key: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", keysUrl: "https://platform.deepseek.com/api_keys" },
];
export const providerSpec = (k: Provider) => PROVIDERS.find((p) => p.key === k) ?? PROVIDERS[0];
export const DEFAULT_AI: AiSettings = { provider: PROVIDERS[0].key, baseUrl: PROVIDERS[0].baseUrl, model: PROVIDERS[0].model, key: "" };
const STORE_KEY = "m3e:ai";
export function loadAiSettings(): AiSettings {
const s = { ...DEFAULT_AI };
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
const v = JSON.parse(raw) as Partial<AiSettings>;
if (PROVIDERS.some((p) => p.key === v.provider)) s.provider = v.provider as Provider;
if (typeof v.baseUrl === "string") s.baseUrl = v.baseUrl;
if (typeof v.model === "string") s.model = v.model;
if (typeof v.key === "string") s.key = v.key;
}
} catch {}
return s;
}
/** Settings live in this browser only, like the document itself. */
export function saveAiSettings(s: AiSettings) {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(s));
} catch {}
}
const isLocal = (u: string) => /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(u.trim());
/** a hosted endpoint needs a key; a server on this machine may run without one */
export const hasKey = (s: AiSettings) => s.key.trim().length > 0 || isLocal(s.baseUrl);
/** the key must not travel over plain http, except to this machine */
export const isSecureUrl = (u: string) => /^https:\/\//i.test(u.trim()) || isLocal(u);
const trimSlash = (u: string) => u.trim().replace(/\/+$/, "");
async function readError(res: Response): Promise<string> {
let detail = "";
try {
const j = await res.json();
detail = j?.error?.message ?? j?.message ?? JSON.stringify(j);
} catch {
try {
detail = await res.text();
} catch {}
}
return `${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 300)}` : ""}`;
}
/** one round trip: a system prompt and a user message in, the model's text out */
export async function complete(s: AiSettings, system: string, user: string, signal?: AbortSignal, maxTokens = 4096): Promise<string> {
const base = trimSlash(s.baseUrl);
const model = s.model.trim();
if (!model) throw new Error("model");
if (!isSecureUrl(base)) throw new Error("insecure");
if (s.provider === "claude") {
const res = await fetch(`${base}/v1/messages`, {
method: "POST",
signal,
headers: {
"content-type": "application/json",
"x-api-key": s.key.trim(),
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({ model, max_tokens: Math.min(maxTokens, 8192), system, messages: [{ role: "user", content: user }] }),
});
if (!res.ok) throw new Error(await readError(res));
const j = await res.json();
if (j.stop_reason === "refusal") throw new Error("refusal");
if (j.stop_reason === "max_tokens") throw new Error("long");
return (j.content ?? [])
.filter((b: { type: string }) => b.type === "text")
.map((b: { text: string }) => b.text)
.join("");
}
const headers: Record<string, string> = { "content-type": "application/json" };
if (s.key.trim()) headers.authorization = `Bearer ${s.key.trim()}`;
const res = await fetch(`${base}/chat/completions`, {
method: "POST",
signal,
headers,
body: JSON.stringify({
model,
/* OpenAI's newer models refuse `max_tokens` and default generously, so they get no budget;
the other compatible endpoints cap around 8k */
...(s.provider === "openai" ? {} : { max_tokens: Math.min(maxTokens, 8192) }),
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
}),
});
if (!res.ok) throw new Error(await readError(res));
const j = await res.json();
if (j.choices?.[0]?.finish_reason === "length") throw new Error("long");
const c = j.choices?.[0]?.message?.content;
if (typeof c === "string") return c;
if (Array.isArray(c)) return c.map((x: { text?: string }) => x.text ?? "").join("");
throw new Error("empty");
}
/** the first JSON object in a reply, with any code fence stripped */
function parseJsonObject(text: string): Record<string, unknown> {
const cleaned = text.replace(/```(?:json)?/gi, "");
const a = cleaned.indexOf("{");
const b = cleaned.lastIndexOf("}");
if (a < 0 || b <= a) throw new Error("json");
const v = JSON.parse(cleaned.slice(a, b + 1));
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("json");
return v as Record<string, unknown>;
}
/* ---------- actions ---------- */
const LANG_NAME: Record<Lang, string> = { ja: "Japanese", en: "English", zh: "Simplified Chinese", ko: "Korean" };
const hasText = (v?: string | null) => !!v && v.trim().length > 0;
const itemsOnFrame = (doc: Doc, frame: Frame, widths: Record<string, number>): Item[] =>
doc.groups.filter((g: Group) => frameOfGroup(g, doc.frames, widths)?.id === frame.id).flatMap((g) => g.items);
const describeItem = (it: Item) => {
const bits = [`id=${it.id}`, `kind=${it.kind}`];
if (hasText(it.label)) bits.push(`label=${JSON.stringify(it.label)}`);
if (hasText(it.supporting)) bits.push(`supporting=${JSON.stringify(it.supporting)}`);
if (it.icon) bits.push(`icon=${it.icon}`);
if (it.tabs?.length) bits.push(`items=${JSON.stringify(it.tabs.map((t) => t.label || t.icon))}`);
if (it.action) bits.push(`tap=${it.action.to}`);
if (it.toggle) bits.push("toggle");
if (hasText(it.note)) bits.push(`current_note=${JSON.stringify(it.note)}`);
return bits.join(" ");
};
const context = (doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang) =>
[
"The author is sketching a Material 3 Expressive app. Below is the generated description of the whole design, then the screen to work on.",
"",
"=== Whole design ===",
buildPrompt(doc, widths, undefined, lang),
"",
`=== Screen to work on: ${JSON.stringify(frame.name || "(unnamed)")} ===`,
].join("\n");
const SYSTEM = "You help an app designer finish a sketch. Answer with a single JSON object and nothing else: no prose, no markdown fence.";
/** picks the strings the model returned for exactly the parts asked about */
function pickStrings(v: unknown, parts: Item[], max: number): Record<string, string> {
const map = (v ?? {}) as Record<string, unknown>;
const out: Record<string, string> = {};
for (const it of parts) {
const s = map[it.id];
if (typeof s === "string" && s.trim()) out[it.id] = s.trim().slice(0, max);
}
return out;
}
/** a short behavior note for one part; an existing note is refined rather than replaced */
export async function proposeBehavior(s: AiSettings, doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang, itemId: string, signal?: AbortSignal): Promise<string | undefined> {
const parts = itemsOnFrame(doc, frame, widths).filter((it) => it.id === itemId);
if (!parts.length) return undefined;
const user = [
context(doc, widths, frame, lang),
"",
"For the part listed below, write what happens when the user interacts with it: what it does, where it leads, what it shows. One sentence, concrete, in the voice of a product spec (no 'should', no hedging). Infer from the labels, icons and the other screens; do not invent screens that do not exist.",
"A part with a current_note already has the author's own wording: keep its intent and facts, and improve it (clearer, more specific, consistent with the rest of the screen). Do not contradict it.",
`Write in ${LANG_NAME[lang]}.`,
"",
"Part:",
describeItem(parts[0]),
"",
'Answer as {"notes": {"<id>": "<sentence>"}} using exactly the id above.',
].join("\n");
const j = parseJsonObject(await complete(s, SYSTEM, user, signal));
return pickStrings(j.notes, parts, 300)[itemId];
}
/** a name (only when the screen has none) and a one-line purpose for the screen; an existing description is refined */
export async function proposeDescription(s: AiSettings, doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang, signal?: AbortSignal): Promise<{ name?: string; note: string }> {
const user = [
context(doc, widths, frame, lang),
"",
"Describe this screen's purpose in one or two sentences: who opens it, what they see and what they can do here. Also propose a short screen name (one to three words).",
hasText(frame.note) ? `The author's current description is ${JSON.stringify(frame.note)}: keep its intent and facts, and improve it.` : "",
`Write in ${LANG_NAME[lang]}.`,
"",
'Answer as {"name": "<name>", "description": "<sentences>"}.',
]
.filter((l) => l !== "")
.join("\n");
const j = parseJsonObject(await complete(s, SYSTEM, user, signal));
const note = typeof j.description === "string" ? j.description.trim().slice(0, 400) : "";
if (!note) throw new Error("json");
const name = typeof j.name === "string" ? j.name.trim().slice(0, 40) : "";
return { name: !hasText(frame.name) && name ? name : undefined, note };
}
/** the value a rewritten field had before, so the rewrite can be undone; an empty field leaves nothing to go back to */
export const pushHistory = (history: string[] | undefined, replaced: string | undefined): string[] | undefined => {
const cur = (replaced ?? "").trim();
return cur ? [cur] : history?.length ? history : undefined;
};
/** the patch that swaps a field with what it said before the AI wrote it; pressing again swaps back */
export function popHistory<V extends string, H extends string>(current: string | undefined, history: string[] | undefined, valueKey: V, historyKey: H): Record<V, string> & Record<H, string[] | undefined> {
const [prev] = history ?? [];
const cur = (current ?? "").trim();
return { [valueKey]: prev ?? "", [historyKey]: cur ? [cur] : undefined } as Record<V, string> & Record<H, string[] | undefined>;
}
/** A whole design from an idea, drafted by the author's own model. `guide` is the same
* agent guide a coding agent reads (public/agent.md), so both paths follow one spec.
* The answer is the document itself; a link would be pointless here. */
export async function draftDesign(s: AiSettings, guide: string, idea: string, lang: Lang, signal?: AbortSignal): Promise<Doc> {
const system = [
"You draft M3E Canvas designs. Follow the guide below exactly.",
"Reply with the JSON document only: no share link, no prose, no markdown fence, no explanation.",
"",
guide,
].join("\n");
const user = [`Sketch this app: ${idea.trim()}`, `Write every label, title and note in ${LANG_NAME[lang]}.`, "Three to five screens. Keep it simple."].join("\n");
const j = parseJsonObject(await complete(s, system, user, signal, 12000));
if (!isProject(j)) throw new Error("json");
return j;
}