diff --git a/docker-compose.yml b/docker-compose.yml index 1957fba..e3b6c37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -262,6 +262,26 @@ services: retries: 5 start_period: 10s + # ── KiCad MCP sidecar (PCB design via SSE) ── + kicad-mcp: + build: ./docker/kicad-mcp + container_name: kicad-mcp + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + + # ── FreeCAD MCP sidecar (3D CAD via SSE) ── + freecad-mcp: + build: ./docker/freecad-mcp + container_name: freecad-mcp + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + # ── Scribus noVNC (rDesign DTP workspace) ── scribus-novnc: build: diff --git a/docker/freecad-mcp/Dockerfile b/docker/freecad-mcp/Dockerfile new file mode 100644 index 0000000..ccd7a19 --- /dev/null +++ b/docker/freecad-mcp/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-slim + +# Install FreeCAD headless (freecad-cmd) and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + freecad \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set headless Qt/FreeCAD env +ENV QT_QPA_PLATFORM=offscreen +ENV DISPLAY="" +ENV FREECAD_USER_CONFIG=/tmp/.FreeCAD + +WORKDIR /app + +# Copy MCP server source +COPY freecad-mcp-server/ . + +# Install Node deps + supergateway (stdio→SSE bridge) +RUN npm install && npm install -g supergateway + +# Ensure generated files dir exists +RUN mkdir -p /data/files/generated + +EXPOSE 8808 + +CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"] diff --git a/docker/kicad-mcp/Dockerfile b/docker/kicad-mcp/Dockerfile new file mode 100644 index 0000000..b803f0c --- /dev/null +++ b/docker/kicad-mcp/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-slim + +# Install KiCad, Python, and build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + kicad \ + python3 \ + python3-pip \ + python3-venv \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Use SWIG backend (headless — no KiCad GUI needed) +ENV KICAD_BACKEND=swig + +WORKDIR /app + +# Copy MCP server source +COPY KiCAD-MCP-Server/ . + +# Install Node deps + supergateway (stdio→SSE bridge) +RUN npm install && npm install -g supergateway + +# Install Python requirements (Pillow, cairosvg, etc.) +RUN pip3 install --break-system-packages -r python/requirements.txt + +# Ensure generated files dir exists +RUN mkdir -p /data/files/generated + +EXPOSE 8809 + +CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"] diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index 077e9f7..27cc74a 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -42,6 +42,7 @@ const MODULE_META: Record | null = null; + + /** Update which modules appear in the picker and switcher dropdowns */ + static setEnabledModules(ids: string[] | null) { + FolkRApp.enabledModuleIds = ids ? new Set(ids) : null; + } + /** Port descriptors for data pipe integration (AC#3) */ static override portDescriptors: PortDescriptor[] = [ { name: "data-in", type: "json", direction: "input" }, @@ -809,7 +818,11 @@ export class FolkRApp extends FolkShape { } #buildSwitcher(switcherEl: HTMLElement) { + const enabledSet = FolkRApp.enabledModuleIds + ?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null); + const items = Object.entries(MODULE_META) + .filter(([id]) => !enabledSet || enabledSet.has(id)) .map(([id, meta]) => ` + + + + `} + ${hasMessages && reactionEntries.length > 0 ? `
${reactionEntries.map(([emoji, users]) => ` @@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
${REACTION_EMOJIS.map(e => ``).join('')}
+ ` : ''} + ${hasMessages && thread.reminderAt ? `
- ${thread.reminderAt - ? `⏰ ${formatDate(thread.reminderAt)}` - : `` - } - + ⏰ ${formatDate(thread.reminderAt)} +
+ ` : ''} + ${hasMessages ? `
- +
+ +
+ ` : ''}
- + ${hasMessages ? ` + + + + ` : ''} +
`; @@ -214,12 +325,21 @@ class NotesCommentPanel extends HTMLElement { `; this.wireEvents(); + + // Auto-focus new comment textarea + requestAnimationFrame(() => { + const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement; + if (newInput) newInput.focus(); + }); } private wireEvents() { // Click thread to scroll editor to it this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { el.addEventListener('click', (e) => { + // Don't handle clicks on inputs/buttons/textareas + const target = e.target as HTMLElement; + if (target.closest('input, textarea, button')) return; const threadId = (el as HTMLElement).dataset.thread; if (!threadId || !this._editor) return; this._activeThreadId = threadId; @@ -236,6 +356,46 @@ class NotesCommentPanel extends HTMLElement { }); }); + // New comment submit (thread with no messages yet) + this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const threadId = (btn as HTMLElement).dataset.submitNew; + if (!threadId) return; + const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement; + const text = textarea?.value?.trim(); + if (!text) return; + this.addReply(threadId, text); + }); + }); + + // New comment cancel — delete the empty thread + this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const threadId = (btn as HTMLElement).dataset.cancelNew; + if (threadId) this.deleteThread(threadId); + }); + }); + + // New comment textarea — Ctrl+Enter to submit, Escape to cancel + this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => { + textarea.addEventListener('keydown', (e) => { + const ke = e as KeyboardEvent; + if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) { + e.stopPropagation(); + const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; + const text = (textarea as HTMLTextAreaElement).value.trim(); + if (threadId && text) this.addReply(threadId, text); + } else if (ke.key === 'Escape') { + e.stopPropagation(); + const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; + if (threadId) this.deleteThread(threadId); + } + }); + textarea.addEventListener('click', (e) => e.stopPropagation()); + }); + // Reply this.shadow.querySelectorAll('[data-reply]').forEach(btn => { btn.addEventListener('click', (e) => { diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 8397615..ba5d9e8 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const useYjs = !isDemo && isEditable; this.contentZone.innerHTML = ` -

- - ${isEditable ? this.renderToolbar() : ''} -