vendor m3e-canvas 5c83ac6, with the import rewrite scripted
upstream imports through a @/ alias, and this platform already binds @/* to src/apps/officer-web and @/components/* to its own shared components — so an unrewritten import resolves to the WRONG module rather than failing. the rewrite is mechanical and lives in scripts/vendor.sh so a re-vendor produces a readable diff. lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them meant editing vendored code, and Canvas.tsx references them throughout. a faithful copy carrying an unused module beats a doctored one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vendored
+254
@@ -0,0 +1,254 @@
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user