fix(rdesign): switch to StreamableHTTP transport, fix KiCad Python path

SSE transport crashes on concurrent connections (supergateway
single-session limit). StreamableHTTP supports multiple sessions.
Also set KICAD_PYTHON=/usr/bin/python3 for existsSync validation
and install missing requests package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 16:48:11 -07:00
parent e0e9802bd7
commit 0db5addc17
6 changed files with 486 additions and 182 deletions

View File

@ -16,7 +16,7 @@ WORKDIR /app
# Copy MCP server source
COPY freecad-mcp-server/ .
# Install Node deps + supergateway (stdio→SSE bridge)
# Install Node deps + supergateway (stdio→HTTP bridge)
RUN npm install && npm install -g supergateway
# Ensure generated files dir exists
@ -24,4 +24,5 @@ RUN mkdir -p /data/files/generated
EXPOSE 8808
CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"]
# Use StreamableHttp (supports multiple concurrent connections, unlike SSE)
CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808", "--outputTransport", "streamableHttp"]

View File

@ -1,31 +1,38 @@
FROM node:20-slim
# Install KiCad, Python, and build dependencies
# Install KiCad (includes pcbnew Python module), 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
# Ensure pcbnew module is findable (installed by kicad package)
ENV PYTHONPATH=/usr/lib/python3/dist-packages
# Point KiCad MCP to system Python (absolute path for existsSync validation)
ENV KICAD_PYTHON=/usr/bin/python3
WORKDIR /app
# Copy MCP server source
COPY KiCAD-MCP-Server/ .
# Remove any venv so the server uses system Python (which has pcbnew)
RUN rm -rf .venv venv
# 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
# Install Python requirements into system Python (Pillow, cairosvg, requests, etc.)
RUN pip3 install --break-system-packages -r python/requirements.txt requests
# Ensure generated files dir exists
RUN mkdir -p /data/files/generated
EXPOSE 8809
CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"]
# Use StreamableHttp (supports multiple concurrent connections, unlike SSE)
CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809", "--outputTransport", "streamableHttp"]

View File

@ -130,14 +130,25 @@ class NotesCommentPanel extends HTMLElement {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; }
.panel { padding: 8px 12px; overflow-y: auto; max-height: calc(100vh - 180px); }
*, *::before, *::after { box-sizing: border-box; }
.panel { padding: 8px 10px; overflow-y: auto; max-height: calc(100vh - 180px); }
.panel.collapsed .thread, .panel.collapsed .panel-empty { display: none; }
.panel-title {
font-weight: 600; font-size: 13px; padding: 8px 0;
color: var(--rs-text-secondary, #666);
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid var(--rs-border-subtle, #f0f0f0);
margin-bottom: 8px;
cursor: pointer; user-select: none;
}
.panel-title:hover { color: var(--rs-text-primary, #111); }
.collapse-btn {
border: none; background: none; cursor: pointer; padding: 2px 4px;
color: var(--rs-text-muted, #999); font-size: 12px; line-height: 1;
border-radius: 4px; transition: transform 0.15s;
}
.collapse-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
.panel.collapsed .collapse-btn { transform: rotate(-90deg); }
.thread {
margin-bottom: 8px;
padding: 12px;
@ -180,10 +191,9 @@ class NotesCommentPanel extends HTMLElement {
/* Reply form — Google Docs style */
.reply-form { margin-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); padding-top: 10px; }
.reply-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
width: 100%; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 6px; font-size: 12px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: none; min-height: 36px;
}
.reply-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.reply-input::placeholder { color: var(--rs-text-muted, #999); }
@ -237,14 +247,17 @@ class NotesCommentPanel extends HTMLElement {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: none; min-height: 60px;
resize: vertical; min-height: 52px; max-height: 150px;
}
.new-comment-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.first-message-text { word-break: break-word; overflow-wrap: anywhere; }
.message-text { word-break: break-word; overflow-wrap: anywhere; }
</style>
<div class="panel">
<div class="panel-title">
<div class="panel" id="comment-panel">
<div class="panel-title" data-action="toggle-collapse">
<span>Comments (${threads.filter(t => !t.resolved).length})</span>
<button class="collapse-btn" title="Minimize">&#9660;</button>
</div>
${threads.map(thread => {
const reactions = thread.reactions || {};
@ -334,6 +347,16 @@ class NotesCommentPanel extends HTMLElement {
}
private wireEvents() {
// Collapse/expand panel
const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]');
if (collapseBtn) {
collapseBtn.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.thread, input, textarea, button:not(.collapse-btn)')) return;
const panel = this.shadow.getElementById('comment-panel');
if (panel) panel.classList.toggle('collapsed');
});
}
// Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => {

View File

@ -36,7 +36,7 @@ import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap';
import { RSpaceYjsProvider } from '../yjs-ws-provider';
import { CommentMark } from './comment-mark';
import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks';
import { createSuggestionPlugin } from './suggestion-plugin';
import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion, acceptAllSuggestions, rejectAllSuggestions } from './suggestion-plugin';
import './comment-panel';
const lowlight = createLowlight(common);
@ -1335,7 +1335,6 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const s = this.getSessionInfo();
return { authorId: s.userId, authorName: s.username };
},
() => this.editor?.view ?? null,
);
this.editor.registerPlugin(suggestionPlugin);
@ -2303,6 +2302,118 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (container) {
container.classList.toggle('suggesting-mode', this.suggestingMode);
}
// Show/hide suggestion review bar
this.updateSuggestionReviewBar();
}
/** Show a review bar when there are pending suggestions. */
private updateSuggestionReviewBar() {
let bar = this.shadow.getElementById('suggestion-review-bar');
if (!this.editor) {
bar?.remove();
return;
}
// Count suggestions
const ids = new Set<string>();
this.editor.state.doc.descendants((node: any) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
ids.add(mark.attrs.suggestionId);
}
}
});
if (ids.size === 0 && !this.suggestingMode) {
bar?.remove();
return;
}
if (!bar) {
bar = document.createElement('div');
bar.id = 'suggestion-review-bar';
bar.className = 'suggestion-review-bar';
// Insert after toolbar
const toolbar = this.shadow.getElementById('editor-toolbar');
if (toolbar?.parentNode) {
toolbar.parentNode.insertBefore(bar, toolbar.nextSibling);
}
}
bar.innerHTML = `
<span class="srb-label">${this.suggestingMode ? 'Suggesting' : 'Editing'}</span>
${ids.size > 0 ? `
<span class="srb-count">${ids.size} suggestion${ids.size !== 1 ? 's' : ''}</span>
<button class="srb-btn srb-accept-all" data-action="accept-all-suggestions" title="Accept all suggestions">Accept All</button>
<button class="srb-btn srb-reject-all" data-action="reject-all-suggestions" title="Reject all suggestions">Reject All</button>
` : '<span class="srb-hint">Start typing to suggest changes</span>'}
`;
// Wire buttons
bar.querySelector('[data-action="accept-all-suggestions"]')?.addEventListener('click', () => {
if (this.editor) {
acceptAllSuggestions(this.editor);
this.updateSuggestionReviewBar();
}
});
bar.querySelector('[data-action="reject-all-suggestions"]')?.addEventListener('click', () => {
if (this.editor) {
rejectAllSuggestions(this.editor);
this.updateSuggestionReviewBar();
}
});
}
/** Show an accept/reject popover near a clicked suggestion mark. */
private showSuggestionPopover(suggestionId: string, authorName: string, type: 'insert' | 'delete', rect: DOMRect) {
// Remove any existing popover
this.shadow.querySelector('.suggestion-popover')?.remove();
const pop = document.createElement('div');
pop.className = 'suggestion-popover';
const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
pop.style.left = `${rect.left - hostRect.left}px`;
pop.style.top = `${rect.bottom - hostRect.top + 4}px`;
pop.innerHTML = `
<div class="sp-header">
<span class="sp-author">${this.esc(authorName)}</span>
<span class="sp-type">${type === 'insert' ? 'Added' : 'Deleted'}</span>
</div>
<div class="sp-actions">
<button class="sp-btn sp-accept" title="Accept">&#10003;</button>
<button class="sp-btn sp-reject" title="Reject">&#10005;</button>
</div>
`;
pop.querySelector('.sp-accept')!.addEventListener('click', () => {
if (this.editor) {
acceptSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
}
pop.remove();
});
pop.querySelector('.sp-reject')!.addEventListener('click', () => {
if (this.editor) {
rejectSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
}
pop.remove();
});
this.shadow.appendChild(pop);
// Close on click outside
const close = (e: Event) => {
if (!pop.contains((e as MouseEvent).target as Node)) {
pop.remove();
this.shadow.removeEventListener('click', close);
}
};
setTimeout(() => this.shadow.addEventListener('click', close), 0);
}
/** Show comment panel for a specific thread. */
@ -2343,7 +2454,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (sidebar) sidebar.classList.remove('has-comments');
}
/** Wire click handling on comment highlights in the editor to open comment panel. */
/** Wire click handling on comment highlights and suggestion marks in the editor. */
private wireCommentHighlightClicks() {
if (!this.editor) return;
@ -2358,15 +2469,36 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
});
// Direct click on comment highlight in the DOM
// On any change, update the suggestion review bar
this.editor.on('update', () => {
this.updateSuggestionReviewBar();
});
// Direct click on comment highlight or suggestion marks in the DOM
const container = this.shadow.getElementById('tiptap-container');
if (container) {
container.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// Comment highlights
const highlight = target.closest?.('.comment-highlight') as HTMLElement;
if (highlight) {
const threadId = highlight.getAttribute('data-thread-id');
if (threadId) this.showCommentPanel(threadId);
return;
}
// Suggestion marks — show accept/reject popover
const suggestionEl = target.closest?.('.suggestion-insert, .suggestion-delete') as HTMLElement;
if (suggestionEl) {
const suggestionId = suggestionEl.getAttribute('data-suggestion-id');
const authorName = suggestionEl.getAttribute('data-author-name') || 'Unknown';
const type = suggestionEl.classList.contains('suggestion-insert') ? 'insert' : 'delete';
if (suggestionId) {
const rect = suggestionEl.getBoundingClientRect();
this.showSuggestionPopover(suggestionId, authorName, type as 'insert' | 'delete', rect);
}
return;
}
});
}
@ -3060,6 +3192,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
.comment-sidebar.has-comments {
width: 280px;
overflow-y: auto;
border-left: 1px solid var(--rs-border, #e5e7eb);
}
@media (max-width: 768px) {
@ -3574,16 +3707,24 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
/* ── Collaboration: Suggestions ── */
.tiptap-container .tiptap .suggestion-insert {
color: #16a34a;
background: rgba(22, 163, 74, 0.08);
border-bottom: 1px dashed #16a34a;
color: #137333;
background: rgba(22, 163, 74, 0.1);
border-bottom: 2px solid rgba(22, 163, 74, 0.4);
text-decoration: none;
cursor: pointer;
}
.tiptap-container .tiptap .suggestion-insert:hover {
background: rgba(22, 163, 74, 0.18);
}
.tiptap-container .tiptap .suggestion-delete {
color: #dc2626;
color: #c5221f;
background: rgba(220, 38, 38, 0.08);
text-decoration: line-through;
text-decoration-color: #dc2626;
text-decoration-color: #c5221f;
cursor: pointer;
}
.tiptap-container .tiptap .suggestion-delete:hover {
background: rgba(220, 38, 38, 0.15);
}
.tiptap-container.suggesting-mode {
border-left: 3px solid #f59e0b;
@ -3592,6 +3733,56 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
background: #f59e0b !important;
color: #fff !important;
}
/* ── Suggestion Review Bar ── */
.suggestion-review-bar {
display: flex; align-items: center; gap: 8px;
padding: 4px 12px; height: 32px;
background: color-mix(in srgb, #f59e0b 8%, var(--rs-bg-surface, #fff));
border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border, #e5e7eb));
font-size: 12px; color: var(--rs-text-secondary, #666);
}
.srb-label { font-weight: 600; color: #b45309; }
.srb-count { margin-left: auto; color: var(--rs-text-muted, #999); }
.srb-hint { color: var(--rs-text-muted, #999); font-style: italic; }
.srb-btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--rs-border, #ddd);
font-size: 11px; cursor: pointer; font-weight: 500; background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #111);
}
.srb-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
.srb-accept-all { color: #137333; border-color: #137333; }
.srb-accept-all:hover { background: rgba(22, 163, 74, 0.08); }
.srb-reject-all { color: #c5221f; border-color: #c5221f; }
.srb-reject-all:hover { background: rgba(220, 38, 38, 0.08); }
/* ── Suggestion Popover (accept/reject on click) ── */
.suggestion-popover {
position: absolute;
z-index: 100;
background: var(--rs-bg-surface, #fff);
border: 1px solid var(--rs-border, #e5e7eb);
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
padding: 8px 10px;
display: flex; flex-direction: column; gap: 6px;
min-width: 140px;
font-size: 12px;
}
.sp-header { display: flex; align-items: center; gap: 6px; }
.sp-author { font-weight: 600; color: var(--rs-text-primary, #111); }
.sp-type { color: var(--rs-text-muted, #999); font-size: 11px; }
.sp-actions { display: flex; gap: 6px; }
.sp-btn {
flex: 1; padding: 4px 0; border: 1px solid var(--rs-border, #ddd);
border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600;
background: var(--rs-bg-surface, #fff); text-align: center;
}
.sp-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
.sp-accept { color: #137333; border-color: #137333; }
.sp-accept:hover { background: rgba(22, 163, 74, 0.08); }
.sp-reject { color: #c5221f; border-color: #c5221f; }
.sp-reject:hover { background: rgba(220, 38, 38, 0.08); }
`;
}
}

View File

@ -1,227 +1,315 @@
/**
* ProseMirror plugin that intercepts transactions in "suggesting" mode
* and converts them into track-changes marks instead of direct edits.
* ProseMirror plugin that intercepts user input in "suggesting" mode
* and converts edits into track-changes marks instead of direct mutations.
*
* In suggesting mode:
* - Insertions wraps inserted text with `suggestionInsert` mark
* - Deletions converts to `suggestionDelete` mark instead of deleting
* - Typed text inserted with `suggestionInsert` mark (green underline)
* - Backspace/Delete text NOT deleted, marked with `suggestionDelete` (red strikethrough)
* - Select + type old text gets `suggestionDelete`, new text gets `suggestionInsert`
* - Paste same as select + type
*
* Accept/reject operations remove marks (and optionally the text).
* Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather
* than filterTransaction for reliability.
*/
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Slice } from '@tiptap/pm/model';
import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin');
interface SuggestionPluginState {
suggesting: boolean;
authorId: string;
authorName: string;
function makeSuggestionId(): string {
return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName }
* @param getView - callback that returns the EditorView (needed to dispatch replacement transactions)
*/
export function createSuggestionPlugin(
getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string },
getView?: () => EditorView | null,
): Plugin {
return new Plugin({
key: pluginKey,
filterTransaction(tr: Transaction, state) {
if (!getSuggesting()) return true;
if (!tr.docChanged) return true;
if (tr.getMeta('suggestion-accept') || tr.getMeta('suggestion-reject')) return true;
if (tr.getMeta('suggestion-applied')) return true;
props: {
/** Intercept typed text — insert with suggestionInsert mark. */
handleTextInput(view: EditorView, from: number, to: number, text: string): boolean {
if (!getSuggesting()) return false;
// Intercept the transaction and convert it to suggestion marks
const { authorId, authorName } = getAuthor();
const suggestionId = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const { state } = view;
const { authorId, authorName } = getAuthor();
const suggestionId = makeSuggestionId();
const tr = state.tr;
// We need to rebuild the transaction with suggestion marks
const newTr = state.tr;
let blocked = false;
// If there's a selection (replacement), mark the selected text as deleted
if (from !== to) {
// Check if selected text is all suggestionInsert from the same author
// → if so, just replace it (editing your own suggestion)
const ownInsert = isOwnSuggestionInsert(state, from, to, authorId);
if (ownInsert) {
tr.replaceWith(from, to, state.schema.text(text, [
state.schema.marks.suggestionInsert.create({
suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(),
}),
]));
tr.setMeta('suggestion-applied', true);
view.dispatch(tr);
return true;
}
tr.steps.forEach((step, i) => {
const stepMap = step.getMap();
let hasInsert = false;
let hasDelete = false;
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.addMark(from, to, deleteMark);
}
stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
if (newEnd > newStart) hasInsert = true;
if (oldEnd > oldStart) hasDelete = true;
// Insert the new text with insert mark after the (marked-for-deletion) text
const insertPos = to;
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.insert(insertPos, state.schema.text(text, [insertMark]));
tr.setMeta('suggestion-applied', true);
// Place cursor after the inserted text
tr.setSelection(TextSelection.create(tr.doc, insertPos + text.length));
view.dispatch(tr);
return true;
},
/** Intercept Backspace/Delete — mark text as deleted instead of removing. */
handleKeyDown(view: EditorView, event: KeyboardEvent): boolean {
if (!getSuggesting()) return false;
if (event.key !== 'Backspace' && event.key !== 'Delete') return false;
const { state } = view;
const { from, to, empty } = state.selection;
const { authorId, authorName } = getAuthor();
let deleteFrom = from;
let deleteTo = to;
if (empty) {
if (event.key === 'Backspace') {
if (from === 0) return true;
deleteFrom = from - 1;
deleteTo = from;
} else {
if (from >= state.doc.content.size) return true;
deleteFrom = from;
deleteTo = from + 1;
}
// Don't cross block boundaries
const $from = state.doc.resolve(deleteFrom);
const $to = state.doc.resolve(deleteTo);
if ($from.parent !== $to.parent) return true;
}
// Backspace/delete on own suggestionInsert → actually remove it
const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId);
if (ownInsert) {
const tr = state.tr;
tr.delete(deleteFrom, deleteTo);
tr.setMeta('suggestion-applied', true);
view.dispatch(tr);
return true;
}
// Already marked as suggestionDelete → skip past it
if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) {
const tr = state.tr;
const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo;
tr.setSelection(TextSelection.create(state.doc, newPos));
view.dispatch(tr);
return true;
}
// Mark the text as deleted
const suggestionId = makeSuggestionId();
const tr = state.tr;
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.addMark(deleteFrom, deleteTo, deleteMark);
tr.setMeta('suggestion-applied', true);
if (event.key === 'Backspace') {
tr.setSelection(TextSelection.create(tr.doc, deleteFrom));
} else {
tr.setSelection(TextSelection.create(tr.doc, deleteTo));
}
view.dispatch(tr);
return true;
},
/** Intercept paste — insert pasted text as a suggestion. */
handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean {
if (!getSuggesting()) return false;
const { state } = view;
const { from, to } = state.selection;
const { authorId, authorName } = getAuthor();
const suggestionId = makeSuggestionId();
const tr = state.tr;
// Mark selected text as deleted
if (from !== to) {
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.addMark(from, to, deleteMark);
}
// Extract text from slice and insert with mark
let pastedText = '';
slice.content.forEach((node: any) => {
if (pastedText) pastedText += '\n';
pastedText += node.textContent;
});
if (hasInsert && !hasDelete) {
// Pure insertion — let it through but add the suggestionInsert mark
blocked = true;
const doc = tr.docs[i];
stepMap.forEach((_oldStart: number, _oldEnd: number, newStart: number, newEnd: number) => {
if (newEnd > newStart) {
const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || '';
if (insertedText) {
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
newTr.insertText(insertedText, newStart);
newTr.addMark(newStart, newStart + insertedText.length, insertMark);
newTr.setMeta('suggestion-applied', true);
}
}
if (pastedText) {
const insertPos = to;
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
} else if (hasDelete && !hasInsert) {
// Pure deletion — convert to suggestionDelete mark instead
blocked = true;
stepMap.forEach((oldStart: number, oldEnd: number) => {
if (oldEnd > oldStart) {
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
newTr.addMark(oldStart, oldEnd, deleteMark);
newTr.setMeta('suggestion-applied', true);
}
});
} else if (hasInsert && hasDelete) {
// Replacement (delete + insert) — mark old text as deleted, new text as inserted
blocked = true;
stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
if (oldEnd > oldStart) {
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
newTr.addMark(oldStart, oldEnd, deleteMark);
}
if (newEnd > newStart) {
const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || '';
if (insertedText) {
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
// Insert after the "deleted" text
const insertPos = oldEnd;
newTr.insertText(insertedText, insertPos);
newTr.addMark(insertPos, insertPos + insertedText.length, insertMark);
}
}
});
newTr.setMeta('suggestion-applied', true);
tr.insert(insertPos, state.schema.text(pastedText, [insertMark]));
tr.setMeta('suggestion-applied', true);
tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length));
}
});
if (blocked && newTr.docChanged) {
// Dispatch our modified transaction instead on the next tick
setTimeout(() => {
const view = getView?.();
if (view) view.dispatch(newTr);
}, 0);
return false; // Block the original transaction
}
return !blocked;
view.dispatch(tr);
return true;
},
},
});
}
/** Check if the range is entirely covered by suggestionInsert marks from the same author. */
function isOwnSuggestionInsert(
state: { doc: any; schema: any },
from: number,
to: number,
authorId: string,
): string | null {
let allOwn = true;
let foundId: string | null = null;
state.doc.nodesBetween(from, to, (node: any) => {
if (!node.isText) return;
const mark = node.marks.find(
(m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId
);
if (!mark) {
allOwn = false;
} else if (!foundId) {
foundId = mark.attrs.suggestionId;
}
});
return allOwn && foundId ? foundId : null;
}
/** Check if the range is already entirely covered by suggestionDelete marks. */
function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean {
let allDeleted = true;
state.doc.nodesBetween(from, to, (node: any) => {
if (!node.isText) return;
if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false;
});
return allDeleted;
}
/**
* Accept a suggestion by removing the mark.
* - For insertions: remove the mark (text stays)
* - For deletions: remove the text and the mark
* Accept a suggestion: insertions stay, deletions are removed.
*/
export function acceptSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
const { doc, tr } = state;
const { tr } = state;
// Find all marks with this suggestionId
doc.descendants((node, pos) => {
// Collect ranges first, apply from end→start to preserve positions
const deleteRanges: [number, number][] = [];
const insertRanges: [number, number, any][] = [];
state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
// Check for suggestionDelete marks — accept = remove the text
const deleteMark = node.marks.find(
m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
(m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
tr.delete(pos, pos + node.nodeSize);
tr.setMeta('suggestion-accept', true);
return false;
deleteRanges.push([pos, pos + node.nodeSize]);
return;
}
// Check for suggestionInsert marks — accept = remove the mark, keep text
const insertMark = node.marks.find(
m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
(m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
tr.removeMark(pos, pos + node.nodeSize, insertMark);
tr.setMeta('suggestion-accept', true);
insertRanges.push([pos, pos + node.nodeSize, insertMark]);
}
});
for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) {
tr.delete(from, to);
}
for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) {
tr.removeMark(from, to, mark);
}
if (tr.docChanged) {
tr.setMeta('suggestion-accept', true);
editor.view.dispatch(tr);
}
}
/**
* Reject a suggestion by reverting it.
* - For insertions: remove the text and the mark
* - For deletions: remove the mark (text stays)
* Reject a suggestion: insertions are removed, deletions stay.
*/
export function rejectSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
const { doc, tr } = state;
const { tr } = state;
doc.descendants((node, pos) => {
const insertRanges: [number, number][] = [];
const deleteRanges: [number, number, any][] = [];
state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
// Check for suggestionInsert marks — reject = remove text + mark
const insertMark = node.marks.find(
m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
(m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
tr.delete(pos, pos + node.nodeSize);
tr.setMeta('suggestion-reject', true);
return false;
insertRanges.push([pos, pos + node.nodeSize]);
return;
}
// Check for suggestionDelete marks — reject = remove the mark, keep text
const deleteMark = node.marks.find(
m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
(m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
tr.removeMark(pos, pos + node.nodeSize, deleteMark);
tr.setMeta('suggestion-reject', true);
deleteRanges.push([pos, pos + node.nodeSize, deleteMark]);
}
});
for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) {
tr.delete(from, to);
}
for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) {
tr.removeMark(from, to, mark);
}
if (tr.docChanged) {
tr.setMeta('suggestion-reject', true);
editor.view.dispatch(tr);
}
}
/**
* Accept all suggestions in the document.
*/
/** Accept all suggestions in the document. */
export function acceptAllSuggestions(editor: Editor) {
const ids = new Set<string>();
editor.state.doc.descendants((node) => {
editor.state.doc.descendants((node: any) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
@ -229,17 +317,13 @@ export function acceptAllSuggestions(editor: Editor) {
}
}
});
for (const id of ids) {
acceptSuggestion(editor, id);
}
for (const id of ids) acceptSuggestion(editor, id);
}
/**
* Reject all suggestions in the document.
*/
/** Reject all suggestions in the document. */
export function rejectAllSuggestions(editor: Editor) {
const ids = new Set<string>();
editor.state.doc.descendants((node) => {
editor.state.doc.descendants((node: any) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
@ -247,7 +331,5 @@ export function rejectAllSuggestions(editor: Editor) {
}
}
});
for (const id of ids) {
rejectSuggestion(editor, id);
}
for (const id of ids) rejectSuggestion(editor, id);
}

View File

@ -1639,18 +1639,18 @@ app.post("/api/blender-gen", async (c) => {
}
});
// KiCAD PCB design — MCP SSE bridge (sidecar container)
// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/sse";
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/mcp";
let kicadClient: Client | null = null;
async function getKicadClient(): Promise<Client> {
if (kicadClient) return kicadClient;
const transport = new SSEClientTransport(new URL(KICAD_MCP_URL));
const transport = new StreamableHTTPClientTransport(new URL(KICAD_MCP_URL));
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
transport.onclose = () => { kicadClient = null; };
@ -1742,14 +1742,14 @@ app.post("/api/kicad/:action", async (c) => {
}
});
// FreeCAD parametric CAD — MCP SSE bridge (sidecar container)
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse";
// FreeCAD parametric CAD — MCP StreamableHTTP bridge (sidecar container)
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/mcp";
let freecadClient: Client | null = null;
async function getFreecadClient(): Promise<Client> {
if (freecadClient) return freecadClient;
const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL));
const transport = new StreamableHTTPClientTransport(new URL(FREECAD_MCP_URL));
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
transport.onclose = () => { freecadClient = null; };