Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc2b1b5b19 | |||
| 213de59f94 | |||
| bf1e7a512e | |||
| 014648807f | |||
| 1014881551 | |||
| 84dd40702c | |||
| 28c545ff6e |
16 changed files with 704 additions and 8 deletions
81
SOURCE.txt
Normal file
81
SOURCE.txt
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
PCBJam — self-hosted fork (CMMS embed)
|
||||||
|
======================================
|
||||||
|
|
||||||
|
This repository is a self-hosted fork of PCBJam (browser-based KiCad compiled to
|
||||||
|
WebAssembly), maintained for embedding the editor in the tas-tech.net CMMS
|
||||||
|
(TT-DB) at /vendor/pcbjam/.
|
||||||
|
|
||||||
|
Upstream
|
||||||
|
--------
|
||||||
|
https://github.com/emergence-engineering/pcbjam
|
||||||
|
tracked here as git remote `upstream`.
|
||||||
|
|
||||||
|
Forked at upstream commit:
|
||||||
|
766e2f409fa8b098555226479742ec694709ea48 (branch main, 2026-09-01)
|
||||||
|
|
||||||
|
Branches
|
||||||
|
--------
|
||||||
|
main pristine mirror of upstream @ 766e2f4 (no local changes)
|
||||||
|
build/host-networking docker/docker-compose.yml: host networking on the build
|
||||||
|
host (its dockerd runs bridge:none + iptables:false for
|
||||||
|
a VPN killswitch, so the image build + compile container
|
||||||
|
use the host network namespace). 1 commit (28c545f).
|
||||||
|
cmms-embed build/host-networking + sub-path mount support so the
|
||||||
|
standalone editor serves under /vendor/pcbjam/ with no
|
||||||
|
change to KiCad/wxWidgets or @pcbjam/shared. See the
|
||||||
|
commit message for the file-by-file breakdown. A pure
|
||||||
|
no-op when built without `--base` (root deploy).
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
Pinned by the superproject gitlinks (nothing in scripts/ overrides them —
|
||||||
|
`KICAD_COMMIT` in scripts/common/versions.sh is unused/informational):
|
||||||
|
|
||||||
|
kicad 56678639eea1b3c0358f384fa47ef7fab78bd09b
|
||||||
|
upstream https://github.com/PCBJam/kicad-source-mirror (branch wasm-port)
|
||||||
|
wxwidgets 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50
|
||||||
|
upstream https://github.com/PCBJam/wxWidgets (branch wasm-port)
|
||||||
|
web/pcbjam-shared a4984c626d847bd07ce66148d6ec96fde8ee8e42
|
||||||
|
upstream https://github.com/PCBJam/pcbjam-shared (branch main)
|
||||||
|
|
||||||
|
.gitmodules still points at PCBJam's GitHub forks — these are NOT mirrored into
|
||||||
|
Forgejo. A checkout needs `git submodule update --init --recursive` against GitHub.
|
||||||
|
|
||||||
|
Toolchain / dependency pins (scripts/common/versions.sh)
|
||||||
|
-------------------------------------------------------
|
||||||
|
Emscripten SDK 6.0.6 (installed from emsdk inside docker/Dockerfile;
|
||||||
|
base image ubuntu:22.04)
|
||||||
|
KiCad 8.99
|
||||||
|
OpenCASCADE 7.8.0
|
||||||
|
wxWidgets 3.3.1
|
||||||
|
Boost 1.84.0
|
||||||
|
Protobuf 3.21.12
|
||||||
|
Python 3.11.5
|
||||||
|
ngspice 46
|
||||||
|
FreeType 2.13.2
|
||||||
|
HarfBuzz 8.3.0
|
||||||
|
Cairo 1.18.0
|
||||||
|
Pixman 0.42.2
|
||||||
|
Zstd 1.5.5
|
||||||
|
GLM 0.9.9.8
|
||||||
|
(every download in versions.sh also carries a SHA256 pin)
|
||||||
|
|
||||||
|
Build
|
||||||
|
-----
|
||||||
|
KiCad WASM: ./docker/build.sh kicad_editor -j 4 --build-deps (debug)
|
||||||
|
./docker/build.sh kicad_editor -j 4 --full --release (ship)
|
||||||
|
Editor UI: cd web && pnpm install --frozen-lockfile
|
||||||
|
pnpm --filter @pcbjam/standalone exec vite build --base=/vendor/pcbjam/
|
||||||
|
(env: web/standalone/.env.production on the cmms-embed branch)
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
KiCad and wxWidgets are GPLv3. PCBJam's own code carries its upstream licenses
|
||||||
|
(see LICENSE and the per-package headers; @pcbjam/shared is MIT). This fork is
|
||||||
|
distributed under the same terms.
|
||||||
|
|
||||||
|
The corresponding source for any deployed WASM build IS this repository at the
|
||||||
|
commit the build was made from, together with the submodule revisions its
|
||||||
|
gitlinks pin (listed above). Repository:
|
||||||
|
|
||||||
|
https://repo.tas-tech.net/AI_Assistant/pcbjam
|
||||||
|
|
@ -3,6 +3,9 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
|
# Build host runs dockerd with bridge:none + iptables:false (PIA killswitch,
|
||||||
|
# no Docker NAT). Image-build RUN steps (emsdk download) use the host netns.
|
||||||
|
network: host
|
||||||
args:
|
args:
|
||||||
# Single source of truth: scripts/common/versions.sh, exported into the env by the scripts
|
# Single source of truth: scripts/common/versions.sh, exported into the env by the scripts
|
||||||
# that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails
|
# that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails
|
||||||
|
|
@ -10,6 +13,10 @@ services:
|
||||||
EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose}
|
EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose}
|
||||||
# Container name is auto-generated with project prefix (set in build.sh)
|
# Container name is auto-generated with project prefix (set in build.sh)
|
||||||
|
|
||||||
|
# The compile container shares the host network namespace so it inherits the
|
||||||
|
# PIA killswitch directly — the build host's dockerd has no bridge/NAT.
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
# Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM).
|
# Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM).
|
||||||
# CI overrides via env: the 32-core Hetzner runner sets KICAD_DOCKER_CPUS=$(nproc)
|
# CI overrides via env: the 32-core Hetzner runner sets KICAD_DOCKER_CPUS=$(nproc)
|
||||||
# and KICAD_DOCKER_MEM=110G — with the 10-CPU default, run 27226030304 compiled
|
# and KICAD_DOCKER_MEM=110G — with the 10-CPU default, run 27226030304 compiled
|
||||||
|
|
|
||||||
33
web/standalone/.env.production
Normal file
33
web/standalone/.env.production
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# CMMS embed (cmms-embed branch). Consumed by `vite build` (mode=production).
|
||||||
|
# Pair with `vite build --base=/vendor/pcbjam/`. All values are public (they ship
|
||||||
|
# in the bundle) — no secrets here.
|
||||||
|
|
||||||
|
# The PHP adapter. Relative so it resolves against the CMMS origin. The adapter
|
||||||
|
# handles PATH_INFO, so /api/scopes/... and /api/me land as
|
||||||
|
# /pcb-project-api.php/api/scopes/... and /pcb-project-api.php/api/me.
|
||||||
|
VITE_API_BASE_URL=/pcb-project-api.php
|
||||||
|
|
||||||
|
# Projects come from the REST adapter above; file bytes fetched + saves PUT back.
|
||||||
|
VITE_PROJECT_SOURCE=remote
|
||||||
|
VITE_DOC_SOURCE=api
|
||||||
|
|
||||||
|
# No library backend for v1. `static` (not `off`): `off` leaves `libsSource` null so boot.ts
|
||||||
|
# never calls installLibsProvider → `window.kicadLibs` is never installed → any C++ path that
|
||||||
|
# resolves a symbol/footprint through the libs bridge on schematic open (park site K1,
|
||||||
|
# sch_io_pcbjam_lib) calls a method on undefined → throws inside a suspending import →
|
||||||
|
# SuspendError → abort. `static` installs the bridge backed by 2 built-in example symbols; it
|
||||||
|
# answers "not found" gracefully for everything else. No backend, no manifest, self-contained.
|
||||||
|
VITE_LIBS_SOURCE=static
|
||||||
|
|
||||||
|
# Flat WASM layout, served static by Apache under the mount (no per-tool subdir,
|
||||||
|
# VITE_WASM_MANIFEST intentionally unset). Files: kicad_editor.{js,wasm}, wx.js,
|
||||||
|
# wx-dom.js, images.tar.gz.
|
||||||
|
VITE_WASM_ROOT=/vendor/pcbjam/wasm
|
||||||
|
|
||||||
|
# Cross-tab only; no collab server.
|
||||||
|
VITE_YJS_PROVIDER=broadcastchannel
|
||||||
|
|
||||||
|
# VITE_APP_URL deliberately UNSET — with it set, every non-editor route would
|
||||||
|
# redirect away from the mount (lib/redirect.ts).
|
||||||
|
# VITE_USER / VITE_SCOPE deliberately UNSET — identity comes from
|
||||||
|
# /pcb-project-api.php/api/me, scope from the URL path segment after the mount.
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Route, Routes, useLocation } from "react-router-dom";
|
import { Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { CmmsAiWidget } from "@/components/CmmsAiWidget";
|
||||||
import { VersionBadge } from "@/components/VersionBadge";
|
import { VersionBadge } from "@/components/VersionBadge";
|
||||||
import { useChromeHidden } from "@/lib/chrome-visibility";
|
import { useChromeHidden } from "@/lib/chrome-visibility";
|
||||||
import { APP_URL } from "@/lib/config";
|
import { APP_URL } from "@/lib/config";
|
||||||
|
|
@ -34,6 +35,9 @@ export default function App() {
|
||||||
{/* Version + source link, bottom-right on every route (home + editor).
|
{/* Version + source link, bottom-right on every route (home + editor).
|
||||||
Keys off the Figma-like hide-UI toggle (hidden is the mobile default). */}
|
Keys off the Figma-like hide-UI toggle (hidden is the mobile default). */}
|
||||||
{!chromeHidden && <VersionBadge />}
|
{!chromeHidden && <VersionBadge />}
|
||||||
|
{/* No-op (fetches config, renders nothing) unless the CMMS embed's
|
||||||
|
ai_assistant module is on for this tenant — see CmmsAiWidget. */}
|
||||||
|
<CmmsAiWidget />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
web/standalone/src/components/CmmsAiWidget.tsx
Normal file
139
web/standalone/src/components/CmmsAiWidget.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// 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() never rejects (its own internal .catch() falls back
|
||||||
|
// to NO_WIDGET) — this .catch() guards the callback body itself instead
|
||||||
|
// (setConfig/installAiBridge/setTheme), so a mistake here becomes a
|
||||||
|
// logged no-op, not a silent unhandled rejection with nothing to say why
|
||||||
|
// the widget didn't appear.
|
||||||
|
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 `.dark`).
|
||||||
|
if (c.dark && !new URLSearchParams(window.location.search).get("theme")) {
|
||||||
|
setTheme("dark");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[cmms-ai-widget] boot failed:", err);
|
||||||
|
});
|
||||||
|
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" }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe title="AI Assistant" src={src} style={{ flex: 1, width: "100%", border: 0, display: "block", minHeight: 0 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { uploadFileBytes } from "@/lib/api";
|
import { uploadFileBytes } from "@/lib/api";
|
||||||
|
import { hardNav } from "@/lib/base-path";
|
||||||
import { currentScope } from "@/lib/config";
|
import { currentScope } from "@/lib/config";
|
||||||
import { localProjectStore } from "@/lib/project-source";
|
import { localProjectStore } from "@/lib/project-source";
|
||||||
import { useLocalProjects } from "@/lib/api";
|
import { useLocalProjects } from "@/lib/api";
|
||||||
|
|
@ -124,7 +125,7 @@ export function NewFileDialog({
|
||||||
// A home-created project is browser-local (@local scope); an in-project new
|
// A home-created project is browser-local (@local scope); an in-project new
|
||||||
// file keeps the current scope. The tool is inferred from the file's ext.
|
// file keeps the current scope. The tool is inferred from the file's ext.
|
||||||
const scope = homeMode ? LOCAL_SCOPE : currentScope();
|
const scope = homeMode ? LOCAL_SCOPE : currentScope();
|
||||||
window.location.assign(projectPath(scope, slug, finalName));
|
hardNav(projectPath(scope, slug, finalName));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
yjsProviderConfig,
|
yjsProviderConfig,
|
||||||
type DocSource,
|
type DocSource,
|
||||||
} from "@/lib/config";
|
} from "@/lib/config";
|
||||||
|
import { stripBase } from "@/lib/base-path";
|
||||||
import { redirectTargetFor } from "@/lib/redirect";
|
import { redirectTargetFor } from "@/lib/redirect";
|
||||||
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
|
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
|
||||||
import { useThemeValue } from "@/lib/theme";
|
import { useThemeValue } from "@/lib/theme";
|
||||||
|
|
@ -46,6 +47,7 @@ import { startFilesWatch, type FilesWatchHandle } from "@/wasm/collab/files-watc
|
||||||
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
||||||
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
||||||
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
|
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
|
||||||
|
import { registerActiveCollabDoc } from "@/wasm/collab/ai-bridge";
|
||||||
import type {
|
import type {
|
||||||
KicadCollabHandle,
|
KicadCollabHandle,
|
||||||
KicadDocSession,
|
KicadDocSession,
|
||||||
|
|
@ -419,6 +421,7 @@ export function WasmTool({
|
||||||
localSelectionRef.current?.destroy();
|
localSelectionRef.current?.destroy();
|
||||||
localSelectionRef.current = null;
|
localSelectionRef.current = null;
|
||||||
setPanelDoc(null);
|
setPanelDoc(null);
|
||||||
|
registerActiveCollabDoc(null);
|
||||||
commentsRef.current?.destroy();
|
commentsRef.current?.destroy();
|
||||||
commentsRef.current = null;
|
commentsRef.current = null;
|
||||||
followRef.current?.destroy();
|
followRef.current?.destroy();
|
||||||
|
|
@ -629,7 +632,10 @@ export function WasmTool({
|
||||||
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
|
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
|
||||||
// management app, so quit goes straight there (one hop instead of letting
|
// management app, so quit goes straight there (one hop instead of letting
|
||||||
// App.tsx's 0006 redirect bounce it).
|
// App.tsx's 0006 redirect bounce it).
|
||||||
const segments = win.location.pathname.split("/").filter(Boolean);
|
// stripBase: segments are relative to the mount, so segments[1] is
|
||||||
|
// "projects"/"libs" even under /vendor/pcbjam/. exitPath stays app-absolute
|
||||||
|
// (no mount prefix) — installQuitHook's hardNav() re-adds it.
|
||||||
|
const segments = stripBase(win.location.pathname).split("/").filter(Boolean);
|
||||||
const exitPath =
|
const exitPath =
|
||||||
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
|
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
|
||||||
const removeQuitHook = installQuitHook(win, {
|
const removeQuitHook = installQuitHook(win, {
|
||||||
|
|
@ -1215,6 +1221,7 @@ export function WasmTool({
|
||||||
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
|
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
|
||||||
startComments(activeRoom?.doc);
|
startComments(activeRoom?.doc);
|
||||||
setPanelDoc(activeRoom?.doc ?? null);
|
setPanelDoc(activeRoom?.doc ?? null);
|
||||||
|
registerActiveCollabDoc(activeRoom?.doc ?? null, activeRoom?.sheetPath);
|
||||||
if (activeRoom && !readOnly) {
|
if (activeRoom && !readOnly) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: activeRoom.doc,
|
doc: activeRoom.doc,
|
||||||
|
|
@ -1269,6 +1276,7 @@ export function WasmTool({
|
||||||
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
||||||
startComments(collabHandle?.doc);
|
startComments(collabHandle?.doc);
|
||||||
setPanelDoc(collabHandle?.doc ?? null);
|
setPanelDoc(collabHandle?.doc ?? null);
|
||||||
|
registerActiveCollabDoc(collabHandle?.doc ?? null, targetPath);
|
||||||
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
|
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
|
||||||
// files a PCB session syncs from fresh in MEMFS, instead of the
|
// 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
|
// one-shot boot snapshot. Same opt-out as the room collab; read-only
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
|
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
|
||||||
// off as soon as any unload/navigation is under way.
|
// off as soon as any unload/navigation is under way.
|
||||||
|
|
||||||
|
import { hardNav } from "@/lib/base-path";
|
||||||
|
|
||||||
let activeQuitHook: (() => void) | undefined;
|
let activeQuitHook: (() => void) | undefined;
|
||||||
let quitHandled = false;
|
let quitHandled = false;
|
||||||
|
|
||||||
|
|
@ -93,7 +95,9 @@ export function installQuitHook(
|
||||||
// wasm stack has unwound.
|
// wasm stack has unwound.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
|
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
|
||||||
win.location.assign(opts.exitUrl);
|
// hardNav: re-add the CMMS mount prefix to the app-absolute exit path
|
||||||
|
// (a full APP_URL redirect target passes through untouched).
|
||||||
|
hardNav(opts.exitUrl, win);
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
toolSchema,
|
toolSchema,
|
||||||
type Tool,
|
type Tool,
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
|
import { hardNav } from "@/lib/base-path";
|
||||||
import { currentScope } from "@/lib/config";
|
import { currentScope } from "@/lib/config";
|
||||||
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
||||||
import { memfsProjectDir } from "@/wasm/constants";
|
import { memfsProjectDir } from "@/wasm/constants";
|
||||||
|
|
@ -175,7 +176,7 @@ export function installToolNavigationHook(
|
||||||
await createFile(relPath, bytes);
|
await createFile(relPath, bytes);
|
||||||
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
|
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
|
||||||
markDeliberateNavigation();
|
markDeliberateNavigation();
|
||||||
win.location.assign(url);
|
hardNav(url, win);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pendingCreate = null;
|
pendingCreate = null;
|
||||||
opts.log(
|
opts.log(
|
||||||
|
|
@ -196,7 +197,7 @@ export function installToolNavigationHook(
|
||||||
|
|
||||||
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
|
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
|
||||||
markDeliberateNavigation();
|
markDeliberateNavigation();
|
||||||
win.location.assign(url);
|
hardNav(url, win);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
57
web/standalone/src/lib/base-path.ts
Normal file
57
web/standalone/src/lib/base-path.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Sub-path mount support (cmms-embed fork).
|
||||||
|
*
|
||||||
|
* The CMMS serves this SPA under `/vendor/pcbjam/`, built with
|
||||||
|
* `vite build --base=/vendor/pcbjam/` — so `import.meta.env.BASE_URL` is that
|
||||||
|
* prefix (always a leading + trailing slash; "/" for a root deploy).
|
||||||
|
*
|
||||||
|
* `<BrowserRouter basename={import.meta.env.BASE_URL}>` already makes
|
||||||
|
* `<Link>` / `navigate()` / `useLocation()` mount-relative. This module covers
|
||||||
|
* the two things the router can't:
|
||||||
|
* 1. code that reads `window.location.pathname` raw (currentScope, WasmTool's
|
||||||
|
* quit-path), and
|
||||||
|
* 2. the hard cross-document `window.location.assign()` navigations — the
|
||||||
|
* WASM runtime is process-global, so a tool switch / File→Quit / new file
|
||||||
|
* is a full page load, not a router transition, and the app paths handed
|
||||||
|
* to it (`@pcbjam/shared` `projectPath()` etc.) are origin-absolute.
|
||||||
|
*
|
||||||
|
* Nothing here touches `@pcbjam/shared` — it also compiles for the backend,
|
||||||
|
* where `import.meta` does not exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `import.meta.env.BASE_URL` without its trailing slash ("" for a root deploy). */
|
||||||
|
export const BASE_PREFIX = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pathname` with the mount prefix removed; the result always starts with "/".
|
||||||
|
* A pathname outside the mount (or a root deploy) is returned unchanged.
|
||||||
|
*/
|
||||||
|
export function stripBase(pathname: string): string {
|
||||||
|
if (
|
||||||
|
BASE_PREFIX &&
|
||||||
|
(pathname === BASE_PREFIX || pathname.startsWith(`${BASE_PREFIX}/`))
|
||||||
|
) {
|
||||||
|
const rest = pathname.slice(BASE_PREFIX.length);
|
||||||
|
return rest === "" ? "/" : rest;
|
||||||
|
}
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `window.location.assign()` to an app-absolute path (e.g. from
|
||||||
|
* `projectPath()`), re-adding the mount prefix. A full URL (has a scheme or is
|
||||||
|
* protocol-relative) passes through untouched — so an `APP_URL` redirect target
|
||||||
|
* is never corrupted.
|
||||||
|
*
|
||||||
|
* `win` is injectable for the same reason the tool-navigation / quit hooks take
|
||||||
|
* a window: test seams and the wx-port's frame window.
|
||||||
|
*/
|
||||||
|
export function hardNav(
|
||||||
|
appPath: string,
|
||||||
|
win: { location: { assign(url: string): void } } = window,
|
||||||
|
): void {
|
||||||
|
const url = /^([a-z][a-z0-9+.-]*:)?\/\//i.test(appPath)
|
||||||
|
? appPath
|
||||||
|
: BASE_PREFIX + appPath;
|
||||||
|
win.location.assign(url);
|
||||||
|
}
|
||||||
70
web/standalone/src/lib/cmms-config.ts
Normal file
70
web/standalone/src/lib/cmms-config.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only `dark` / `aiEnabled` / `aiEditorKind` actually gate behavior — a
|
||||||
|
// bug found 2026-09-05 in browser verification required all six fields as
|
||||||
|
// strict strings, so the real server response (which omits `entityType` /
|
||||||
|
// `entityId`) failed validation and silently fell back to NO_WIDGET despite
|
||||||
|
// `aiEnabled: true` — the widget never mounted and nothing logged why.
|
||||||
|
// `csrfToken` / `entityType` / `entityId` are only used to build the iframe's
|
||||||
|
// query string (harmless as `""`), so a missing one shouldn't disable the
|
||||||
|
// widget entirely.
|
||||||
|
function isCmmsConfigCore(v: unknown): v is Pick<CmmsConfig, "dark" | "aiEnabled" | "aiEditorKind"> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(v: unknown): string {
|
||||||
|
return typeof v === "string" ? v : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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): CmmsConfig => {
|
||||||
|
if (!isCmmsConfigCore(v)) return NO_WIDGET;
|
||||||
|
const c = v as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
dark: v.dark,
|
||||||
|
aiEnabled: v.aiEnabled,
|
||||||
|
aiEditorKind: v.aiEditorKind,
|
||||||
|
csrfToken: optionalString(c.csrfToken),
|
||||||
|
entityType: optionalString(c.entityType),
|
||||||
|
entityId: optionalString(c.entityId),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(() => NO_WIDGET);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
@ -133,6 +133,7 @@ export const LOCAL_PROJECTS_ENABLED =
|
||||||
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
||||||
|
|
||||||
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
||||||
|
import { stripBase } from "@/lib/base-path";
|
||||||
import { sessionIdentity } from "@/lib/session-identity";
|
import { sessionIdentity } from "@/lib/session-identity";
|
||||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||||
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
||||||
|
|
@ -282,7 +283,11 @@ export const PRESENCE_TUNER_ENABLED = import.meta.env.VITE_PRESENCE_TUNER === "1
|
||||||
*/
|
*/
|
||||||
export function currentScope(): string {
|
export function currentScope(): string {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const seg = window.location.pathname.split("/").filter(Boolean)[0];
|
// stripBase: under the CMMS sub-path mount the scope is the first segment
|
||||||
|
// AFTER /vendor/pcbjam/, not "vendor". No-op for a root deploy.
|
||||||
|
const seg = stripBase(window.location.pathname)
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)[0];
|
||||||
if (seg && seg !== "projects" && seg !== "libs") {
|
if (seg && seg !== "projects" && seg !== "libs") {
|
||||||
return decodeURIComponent(seg);
|
return decodeURIComponent(seg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ const queryClient = new QueryClient({
|
||||||
// instantiate exactly once per navigation.
|
// instantiate exactly once per navigation.
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
{/* basename = the vite `base` (import.meta.env.BASE_URL) so the CMMS
|
||||||
|
sub-path mount (/vendor/pcbjam/) is transparent to <Link>/navigate();
|
||||||
|
the hard location.assign() navs use hardNav() (lib/base-path.ts). */}
|
||||||
|
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
|
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
|
||||||
|
import { hardNav } from "@/lib/base-path";
|
||||||
import { downloadBytes } from "@/lib/download";
|
import { downloadBytes } from "@/lib/download";
|
||||||
import { localProjectStore } from "@/lib/project-source";
|
import { localProjectStore } from "@/lib/project-source";
|
||||||
import { zipFiles } from "@/lib/zip";
|
import { zipFiles } from "@/lib/zip";
|
||||||
|
|
@ -108,7 +109,7 @@ export function ProjectView() {
|
||||||
// scoped with a full reload; document tools create a templated file first.
|
// scoped with a full reload; document tools create a templated file first.
|
||||||
const launchTool = (tool: Tool) => {
|
const launchTool = (tool: Tool) => {
|
||||||
if (FILELESS_TOOLS.has(tool)) {
|
if (FILELESS_TOOLS.has(tool)) {
|
||||||
window.location.assign(projectToolPath(scope, slug, tool));
|
hardNav(projectToolPath(scope, slug, tool));
|
||||||
} else {
|
} else {
|
||||||
setNewFileTool(tool);
|
setNewFileTool(tool);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
web/standalone/src/wasm/collab/ai-bridge.test.ts
Normal file
104
web/standalone/src/wasm/collab/ai-bridge.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { docToFile, docToY, kicadLibSymbolsMap, fileToDoc, yToDoc } from "@pcbjam/shared";
|
||||||
|
import { applyToActiveBuffer, readActiveBuffer, registerActiveCollabDoc } from "./ai-bridge";
|
||||||
|
|
||||||
|
// A symbol instance is only renderable with its `(lib_symbols …)` definition.
|
||||||
|
// `docDelta` (the apply path's item differ) only diffs uuid-bearing items, and a
|
||||||
|
// `(symbol "Device:R" …)` lib def has no uuid — so an AI proposal that places a
|
||||||
|
// part the sheet has never carried used to land the instance with no definition,
|
||||||
|
// and KiCad drew nothing. `applyToActiveBuffer` now upserts the proposal's lib
|
||||||
|
// defs into `kdoc_libsymbols` alongside the item delta.
|
||||||
|
|
||||||
|
const WIRE1 = "22222222-0000-0000-0000-000000000001";
|
||||||
|
const SYM1 = "44444444-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
const SCH_WITHOUT_SYMBOL = `(kicad_sch
|
||||||
|
\t(version 20250114)
|
||||||
|
\t(generator "eeschema")
|
||||||
|
\t(generator_version "9.0")
|
||||||
|
\t(uuid "11111111-1111-1111-1111-111111111111")
|
||||||
|
\t(paper "A4")
|
||||||
|
\t(lib_symbols)
|
||||||
|
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
|
||||||
|
\t(sheet_instances (path "/" (page "1")))
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SCH_WITH_SYMBOL = `(kicad_sch
|
||||||
|
\t(version 20250114)
|
||||||
|
\t(generator "eeschema")
|
||||||
|
\t(generator_version "9.0")
|
||||||
|
\t(uuid "11111111-1111-1111-1111-111111111111")
|
||||||
|
\t(paper "A4")
|
||||||
|
\t(lib_symbols
|
||||||
|
\t\t(symbol "Device:R" (pin_numbers (hide yes)) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
|
||||||
|
\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
|
||||||
|
\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
|
||||||
|
\t\t\t(symbol "R_0_1"
|
||||||
|
\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
|
||||||
|
\t\t\t)
|
||||||
|
\t\t\t(symbol "R_1_1"
|
||||||
|
\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
|
||||||
|
\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
|
||||||
|
\t\t\t)
|
||||||
|
\t\t)
|
||||||
|
\t)
|
||||||
|
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
|
||||||
|
\t(symbol (lib_id "Device:R") (at 63.5 63.5 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no)
|
||||||
|
\t\t(uuid "${SYM1}")
|
||||||
|
\t\t(property "Reference" "R1" (at 66.04 62.23 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||||
|
\t\t(property "Value" "10k" (at 66.04 64.77 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||||
|
\t\t(pin "1" (uuid "44444444-0000-0000-0000-0000000000a1"))
|
||||||
|
\t\t(pin "2" (uuid "44444444-0000-0000-0000-0000000000a2"))
|
||||||
|
\t\t(instances (project "rt" (path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1))))
|
||||||
|
\t)
|
||||||
|
\t(sheet_instances (path "/" (page "1")))
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
function seed(src: string): Y.Doc {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
docToY(fileToDoc(src), ydoc);
|
||||||
|
return ydoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ai-bridge applyToActiveBuffer", () => {
|
||||||
|
it("carries a newly-placed symbol's (lib_symbols) definition, not just the instance", () => {
|
||||||
|
const ydoc = seed(SCH_WITHOUT_SYMBOL);
|
||||||
|
registerActiveCollabDoc(ydoc, "board.kicad_sch");
|
||||||
|
try {
|
||||||
|
expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBeUndefined();
|
||||||
|
|
||||||
|
applyToActiveBuffer(SCH_WITH_SYMBOL);
|
||||||
|
|
||||||
|
const def = kicadLibSymbolsMap(ydoc).get("Device:R");
|
||||||
|
expect(def).toBeDefined();
|
||||||
|
expect(def).toContain("R_0_1");
|
||||||
|
|
||||||
|
// and the instance item itself landed via the normal item delta
|
||||||
|
const out = docToFile(yToDoc(ydoc));
|
||||||
|
expect(out).toContain(SYM1);
|
||||||
|
expect(out).toContain('(lib_id "Device:R")');
|
||||||
|
} finally {
|
||||||
|
registerActiveCollabDoc(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op on lib_symbols when the proposal adds no new definitions", () => {
|
||||||
|
const ydoc = seed(SCH_WITH_SYMBOL);
|
||||||
|
registerActiveCollabDoc(ydoc, "board.kicad_sch");
|
||||||
|
try {
|
||||||
|
const before = kicadLibSymbolsMap(ydoc).get("Device:R");
|
||||||
|
expect(before).toBeDefined();
|
||||||
|
|
||||||
|
// same doc, wire nudged — no new parts
|
||||||
|
applyToActiveBuffer(SCH_WITH_SYMBOL.replace("101.6 50.8", "127 50.8"));
|
||||||
|
|
||||||
|
expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBe(before);
|
||||||
|
expect(readActiveBuffer()).toContain("127 50.8");
|
||||||
|
} finally {
|
||||||
|
registerActiveCollabDoc(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
178
web/standalone/src/wasm/collab/ai-bridge.ts
Normal file
178
web/standalone/src/wasm/collab/ai-bridge.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// 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,
|
||||||
|
libSymbolsFromLayout,
|
||||||
|
upsertLibSymbolsToY,
|
||||||
|
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);
|
||||||
|
// `docDelta` only diffs uuid-bearing items — a `(lib_symbols …)` def has no
|
||||||
|
// uuid, so a proposal that places a symbol the sheet has never carried adds
|
||||||
|
// the instance item but drops its library definition, leaving KiCad an
|
||||||
|
// instance pointing at an undefined symbol (renders nothing). Push the
|
||||||
|
// proposal's lib defs into the kdoc_libsymbols map FIRST (upsert by lib id,
|
||||||
|
// never deletes — the server always splices into the existing block, so this
|
||||||
|
// is existing ∪ additions), so the items observer's `deltaToItemsWire` can
|
||||||
|
// prefix each new instance with its `(lib_symbols …)` on the way to the
|
||||||
|
// editor — exactly what `kicad-binding.ts`'s own DOWN path does with
|
||||||
|
// `upsertLibSymbolsToY(doc, defs, ORIGIN)`. Deliberately NOT `syncLayoutToY`:
|
||||||
|
// that also reconciles non-item layout heads (root `uuid`, `sheet_instances`,
|
||||||
|
// `paper`…) wholesale-LWW from the proposal, which would corrupt the sheet's
|
||||||
|
// identity if the server's spliced preamble deviates at all.
|
||||||
|
upsertLibSymbolsToY(activeDoc, libSymbolsFromLayout(next.layout, next.items), APPLY_ORIGIN);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue