cmms-embed: kicad AI chat editor-buffer bridge (PCBJam Part 2)
Some checks failed
wasm-build.yml / cmms-embed: kicad AI chat editor-buffer bridge (PCBJam Part 2) (push) Failing after 0s
wasm-build.yml / cmms-embed: kicad AI chat editor-buffer bridge (PCBJam Part 2) (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled

web/standalone only, no C++/wasm/shared changes:

- lib/cmms-config.ts (new): fetch /api/cmms-config once on boot
  (dark, aiEnabled, aiEditorKind, csrfToken, entityType, entityId).
  Absent route / non-2xx / malformed JSON all mean "no widget" - never
  throws, never blocks boot. Same API base + credentials:"include" as
  the existing /api/me call.
- components/CmmsAiWidget.tsx (new): floating launcher + panel, inert
  unless aiEnabled. Mounted once in App.tsx alongside VersionBadge.
  Mirrors render_ai_assistant_editor_embed()'s markup/CSS shape
  (position, colors) but fixed-position - the draggable/resizable
  geometry system is CMMS-side static JS this bundle can't import.
  Follows `dark` from the config fetch, belt-and-braces alongside the
  existing `?theme=` hand-off (which already wins whenever set).
- wasm/collab/ai-bridge.ts (new): the cmms-ai-editor-* postMessage
  protocol (same shape as the cad-editor / pcb-retrace bridges).
  get-buffer: yToDoc + docToFile on the active sheet's live Y.Doc
  (read-only, reflects in-progress edits including a peer's - not
  last-saved-to-MEMFS). apply: docDelta + applyDeltaToY, treating the
  AI's proposed file as though a peer had made the edit, through the
  same item-observer path every real co-edit already takes - NOT a
  reuse of KicadBinding.seed(), which is a boot-time/reconnect-only
  function (three branches, each with its own empty/hollow-doc edge
  cases) that doesn't have a branch for "live populated doc gets
  replaced with unrelated content", and this subsystem's git history
  (sibling-restage, paste-collision, ysync 0001-0008) is almost
  entirely bugfixes in exactly that state-transition space.
- WasmTool.tsx: registerActiveCollabDoc(doc, filePath) mirrored onto
  each of the three existing setPanelDoc(...) call sites (single-room
  collabHandle, multi-sheet onActiveChange, teardown) - reuses the
  existing "which doc does the SheetPanel show right now" signal
  rather than adding new state.

Known gaps, flagged not silently skipped:
- docDelta only diffs items, not layout/preamble (page size, title
  block) - matches its own doc comment; an AI edit to those won't
  apply. Items (symbols/footprints/wires) are the expected AI edit
  surface for now.
- No client-side read-only-viewer gate on apply - a tab opened as a
  read-only viewer has no local-edit path, but this pushes the AI's
  delta into the Y.Doc the same as a remote peer would, which a
  viewer's binding was never checked against. Write authorization is
  expected to live server-side (the AI tool implementation deciding
  whether to call write_editor_buffer at all, matching the
  cad_generation_enabled gate on the cad-editor bridge) - worth an
  explicit look before this ships broadly.

Verified: tsc --noEmit clean, vitest run 469/469 passing, vite build
succeeds. No kicad/wxwidgets changes - the existing wasm/ build
(vendor-766e2f4-20260904-r4-debug) is unaffected; this is a dist-only
release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AI_Assistant 2026-09-05 20:21:17 +10:00
commit bf1e7a512e
5 changed files with 354 additions and 0 deletions

View file

@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { CmmsAiWidget } from "@/components/CmmsAiWidget";
import { VersionBadge } from "@/components/VersionBadge";
import { useChromeHidden } from "@/lib/chrome-visibility";
import { APP_URL } from "@/lib/config";
@ -34,6 +35,9 @@ export default function App() {
{/* Version + source link, bottom-right on every route (home + editor).
Keys off the Figma-like hide-UI toggle (hidden is the mobile default). */}
{!chromeHidden && <VersionBadge />}
{/* No-op (fetches config, renders nothing) unless the CMMS embed's
ai_assistant module is on for this tenant — see CmmsAiWidget. */}
<CmmsAiWidget />
</>
);
}

View file

@ -0,0 +1,130 @@
// cmms-embed / PCBJam Part 2: floating AI-assistant launcher + panel, for the
// CMMS embed only. Inert (renders nothing) unless `/api/cmms-config` answers
// `aiEnabled: true` — a plain pcbjam.io deploy or a CMMS tenant with the
// module off never fetches or renders anything here beyond the one config
// call. Visual language (position, colors, toggle/panel shape) mirrors
// `render_ai_assistant_editor_embed()` (TT-DB `src/ai_assistant_widget.php`)
// so it reads as the same widget as every other embedded editor; the
// draggable/resizable geometry system that PHP version has is CMMS-side
// static JS this bundle can't import, so this is fixed-position (bottom
// right) — a reasonable v1 given the panel is small relative to the board
// canvas.
import * as React from "react";
import { fetchCmmsConfig, type CmmsConfig } from "@/lib/cmms-config";
import { getActiveFilePath, installAiBridge } from "@/wasm/collab/ai-bridge";
import { setTheme } from "@/lib/theme";
export function CmmsAiWidget(): React.ReactElement | null {
const [config, setConfig] = React.useState<CmmsConfig | null>(null);
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
fetchCmmsConfig().then((c) => {
if (cancelled) return;
setConfig(c);
if (c.aiEnabled) installAiBridge();
// Belt-and-braces alongside the `?theme=` hand-off (lib/theme.ts) — that
// param already wins whenever edit.php sets it, so this only matters if
// it's ever absent. Skip if the URL param already ran; setTheme is
// otherwise idempotent (persists + toggles the same `.dark` class).
if (c.dark && !new URLSearchParams(window.location.search).get("theme")) {
setTheme("dark");
}
});
return () => {
cancelled = true;
};
}, []);
if (!config?.aiEnabled) return null;
// Computed at render time (not memoized) so re-opening the panel after
// switching sheets sends the file that's actually open now — a context
// hint only; get-buffer carries the real content regardless.
const params = new URLSearchParams({
editor: "kicad",
editor_entity_type: config.entityType,
editor_entity_id: config.entityId,
editor_filename: getActiveFilePath() ?? "",
page_context: "PCBJam (KiCad editor)",
});
const src = `/modules/ai_assistant/widget-frame.php?${params.toString()}`;
return (
<div id="cmms-ai-widget">
<button
type="button"
aria-label="AI Assistant"
title="AI Assistant"
onClick={() => setOpen((v) => !v)}
style={{
position: "fixed",
right: 16,
bottom: 16,
zIndex: 2147483000,
width: 48,
height: 48,
borderRadius: "50%",
border: "1px solid rgba(0,0,0,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ffffff",
color: "#1e293b",
cursor: "pointer",
padding: 0,
boxShadow: "0 2px 12px rgba(0,0,0,0.3)",
}}
>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path d="M4 4h16v12H8l-4 4V4z" strokeLinejoin="round" strokeLinecap="round" />
</svg>
</button>
{open && (
<div
style={{
position: "fixed",
right: 16,
bottom: 76,
zIndex: 2147483000,
width: 380,
height: 560,
border: "1px solid rgba(0,0,0,0.15)",
borderRadius: 8,
overflow: "hidden",
boxShadow: "0 6px 32px rgba(0,0,0,0.35)",
background: "#ffffff",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
flex: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
background: "#f1f5f9",
color: "#1e293b",
font: "600 13px system-ui, sans-serif",
}}
>
<span>AI Assistant</span>
<button
type="button"
aria-label="Close"
title="Close"
onClick={() => setOpen(false)}
style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", fontSize: 16, padding: "2px 6px" }}
>
&times;
</button>
</div>
<iframe title="AI Assistant" src={src} style={{ flex: 1, width: "100%", border: 0, display: "block", minHeight: 0 }} />
</div>
)}
</div>
);
}

View file

@ -47,6 +47,7 @@ import { startFilesWatch, type FilesWatchHandle } from "@/wasm/collab/files-watc
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
import { registerActiveCollabDoc } from "@/wasm/collab/ai-bridge";
import type {
KicadCollabHandle,
KicadDocSession,
@ -420,6 +421,7 @@ export function WasmTool({
localSelectionRef.current?.destroy();
localSelectionRef.current = null;
setPanelDoc(null);
registerActiveCollabDoc(null);
commentsRef.current?.destroy();
commentsRef.current = null;
followRef.current?.destroy();
@ -1219,6 +1221,7 @@ export function WasmTool({
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
startComments(activeRoom?.doc);
setPanelDoc(activeRoom?.doc ?? null);
registerActiveCollabDoc(activeRoom?.doc ?? null, activeRoom?.sheetPath);
if (activeRoom && !readOnly) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
@ -1273,6 +1276,7 @@ export function WasmTool({
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc);
setPanelDoc(collabHandle?.doc ?? null);
registerActiveCollabDoc(collabHandle?.doc ?? null, targetPath);
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
// files a PCB session syncs from fresh in MEMFS, instead of the
// one-shot boot snapshot. Same opt-out as the room collab; read-only

View file

@ -0,0 +1,54 @@
// cmms-embed / PCBJam Part 2: fetch the AI-widget config the CMMS side
// exposes at boot. Mirrors the existing `/api/me` identity call (same-origin,
// `credentials: "include"` for the session cookie) — see lib/api.ts.
//
// Absent route, non-2xx, or malformed JSON all mean "no AI widget" (a plain
// pcbjam.io deploy, or a CMMS build where the module is off) — never throws,
// never blocks boot.
import { API_BASE_URL } from "@/lib/config";
export interface CmmsConfig {
dark: boolean;
aiEnabled: boolean;
aiEditorKind: "kicad";
csrfToken: string;
entityType: string;
entityId: string;
}
function isCmmsConfig(v: unknown): v is CmmsConfig {
if (!v || typeof v !== "object") return false;
const c = v as Record<string, unknown>;
return (
typeof c.dark === "boolean" &&
typeof c.aiEnabled === "boolean" &&
c.aiEditorKind === "kicad" &&
typeof c.csrfToken === "string" &&
typeof c.entityType === "string" &&
typeof c.entityId === "string"
);
}
const NO_WIDGET: CmmsConfig = {
dark: false,
aiEnabled: false,
aiEditorKind: "kicad",
csrfToken: "",
entityType: "",
entityId: "",
};
let cached: Promise<CmmsConfig> | null = null;
/** Fetched once per page load; every caller shares the same in-flight/settled
* promise. Same API base as the rest of the app (`VITE_API_BASE_URL`,
* `/pcb-project-api.php` in the CMMS build) — the route lives alongside
* `/api/me` on that same PHP endpoint. */
export function fetchCmmsConfig(): Promise<CmmsConfig> {
if (!cached) {
cached = fetch(`${API_BASE_URL}/api/cmms-config`, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((v) => (isCmmsConfig(v) ? v : NO_WIDGET))
.catch(() => NO_WIDGET);
}
return cached;
}

View file

@ -0,0 +1,162 @@
// cmms-embed / PCBJam Part 2: AI chat editor-buffer bridge.
//
// Same `cmms-ai-editor-*` postMessage protocol as the cad-editor and
// pcb-retrace bridges (see TT-DB docs/vendored/{cad-editor,pcb-retrace}.md):
// the chat is a nested iframe, this top-level page is the "editor" side.
//
// get-buffer: reads the ACTIVE sheet's live Y.Doc (not last-saved-to-MEMFS —
// this reflects in-progress edits, including a peer's) via `yToDoc` +
// `docToFile`, both pure/read-only.
//
// apply: deliberately does NOT reuse `KicadBinding.seed()`. `seed()` is a
// boot-time/reconnect-only function (three branches: editor-matches-doc,
// empty-room file-seed, adopt-existing-room) with its own empty/hollow-doc
// edge cases (see kicad-binding.ts) — none of those branches is "a live,
// already-populated doc gets replaced with unrelated new content", and this
// file's git history (sibling-restage, paste-collision, ysync 0001-0008) is
// almost entirely bugfixes in exactly that state-transition space. Instead,
// this treats the AI's proposed file exactly like a REMOTE PEER'S EDIT
// arriving: diff the two `KicadDoc`s (`docDelta`) and push the result through
// `applyDeltaToY` with an origin that does not match the binding's internal
// `ORIGIN` object, so the normal item-observer chain (the most exercised path
// in this codebase — every multi-user co-edit goes through it) picks it up
// and pushes it into the live WASM editor, the same as any other client's
// change would. Known limitation: `docDelta` only diffs items, not
// layout/preamble (page size, title block) — matches its own doc comment.
import {
applyDeltaToY,
docDelta,
docToFile,
fileToDoc,
yToDoc,
type KicadDoc,
} from "@pcbjam/shared";
import type * as Y from "yjs";
import { clog, cwarn } from "@/wasm/collab/debug";
const APPLY_ORIGIN = { external: "cmms-ai-assistant" };
// The active sheet's Y.Doc — registered by WasmTool.tsx at each of its three
// `setPanelDoc(...)` call sites (single-room collabHandle, multi-sheet
// onActiveChange, and teardown-to-null), the same signal it already uses as
// "which doc does the SheetPanel show right now". A module-level singleton
// rather than prop-drilled: the widget mounts in App.tsx, several route
// levels above WasmTool, matching the ambient `window.kicadCollab` /
// `window.CMMS` cross-boundary pattern the other editor bridges already use.
let activeDoc: Y.Doc | null = null;
let activeFilePath: string | undefined;
export function registerActiveCollabDoc(doc: Y.Doc | null, filePath?: string): void {
activeDoc = doc;
activeFilePath = doc ? filePath : undefined;
}
/** The active sheet's path, for the widget iframe's `editor_filename` param —
* a context hint only, not load-bearing (get-buffer carries the real
* content). `undefined` before any sheet is open. */
export function getActiveFilePath(): string | undefined {
return activeFilePath;
}
export class AiBridgeNotReadyError extends Error {
constructor() {
super("no active KiCad sheet to read/write");
this.name = "AiBridgeNotReadyError";
}
}
/** Live S-expr text of the currently active sheet — read-only, never touches
* the Y.Doc. Safe to call any time a sheet is open. */
export function readActiveBuffer(): string {
if (!activeDoc) throw new AiBridgeNotReadyError();
const doc: KicadDoc = yToDoc(activeDoc);
return docToFile(doc);
}
/** Diff `newSource` against the active sheet's current doc and push the
* result in as though a peer had made the edit. Throws on a source that
* doesn't parse as a KiCad s-expr document (`fileToDoc`) — the caller
* should surface that as a rejected proposal, not a crash. */
export function applyToActiveBuffer(newSource: string): void {
if (!activeDoc) throw new AiBridgeNotReadyError();
const prev = yToDoc(activeDoc);
const next = fileToDoc(newSource);
const delta = docDelta(prev, next);
applyDeltaToY(activeDoc, delta, APPLY_ORIGIN);
}
interface GetBufferMessage {
type: "cmms-ai-editor-get-buffer";
}
interface ApplyMessage {
type: "cmms-ai-editor-apply";
editor: "kicad";
new_source: string;
}
function isGetBufferMessage(d: unknown): d is GetBufferMessage {
return !!d && typeof d === "object" && (d as { type?: unknown }).type === "cmms-ai-editor-get-buffer";
}
function isApplyMessage(d: unknown): d is ApplyMessage {
return (
!!d &&
typeof d === "object" &&
(d as { type?: unknown }).type === "cmms-ai-editor-apply" &&
(d as { editor?: unknown }).editor === "kicad" &&
typeof (d as { new_source?: unknown }).new_source === "string"
);
}
/** Reply to the chat iframe. Prefer `evt.source`; a DOM fallback covers the
* cross-realm-Window case cad-editor's history flagged (see its bridge). */
function replyToChat(evtSource: MessageEventSource | null, msg: Record<string, unknown>): void {
let posted = false;
try {
if (evtSource && "postMessage" in evtSource) {
(evtSource as Window).postMessage(msg, window.location.origin);
posted = true;
}
} catch {
/* fall through to the DOM-query fallback */
}
if (!posted) {
try {
const f = document.querySelector<HTMLIFrameElement>("#cmms-ai-widget iframe");
if (f?.contentWindow) {
f.contentWindow.postMessage(msg, window.location.origin);
posted = true;
}
} catch {
/* nothing left to try */
}
}
clog("ai-bridge: reply", msg.type, posted ? "sent" : "DROPPED");
}
let installed = false;
/** Install the window `message` listener once per page load. Idempotent —
* App.tsx mounts the widget once, but this guards against remounts. */
export function installAiBridge(): void {
if (installed) return;
installed = true;
window.addEventListener("message", (evt: MessageEvent) => {
if (evt.origin !== window.location.origin) return;
const d: unknown = evt.data;
if (isGetBufferMessage(d)) {
let buffer: string | null = null;
try {
buffer = readActiveBuffer();
} catch (err) {
cwarn("ai-bridge: get-buffer failed", err);
}
replyToChat(evt.source, { type: "cmms-ai-editor-buffer", buffer });
} else if (isApplyMessage(d)) {
try {
applyToActiveBuffer(d.new_source);
} catch (err) {
cwarn("ai-bridge: apply failed", err);
}
}
});
}