Merge branch 'dev'
This commit is contained in:
commit
038e9030db
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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">▼</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) => {
|
||||
|
|
|
|||
|
|
@ -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">✓</button>
|
||||
<button class="sp-btn sp-reject" title="Reject">✕</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); }
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=7"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=8"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3285,8 +3285,8 @@ class FolkWalletViewer extends HTMLElement {
|
|||
private renderDashboard(): string {
|
||||
if (!this.hasData()) return "";
|
||||
|
||||
// Aggregate stats across ALL chains (ignoring filter)
|
||||
const allBalances = this.getUnifiedBalances(true);
|
||||
// Stats reflect current filter (all chains when no filter active)
|
||||
const allBalances = this.getUnifiedBalances();
|
||||
const totalUSD = allBalances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
||||
const totalTokens = allBalances.filter((b) => {
|
||||
const fiat = parseFloat(b.fiatBalance || "0");
|
||||
|
|
|
|||
|
|
@ -42,15 +42,23 @@ const TTL = 5 * 60 * 1000; // 5 minutes
|
|||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<CacheEntry>>();
|
||||
|
||||
const CG_API_KEY = process.env.COINGECKO_API_KEY || "";
|
||||
|
||||
function cgUrl(url: string): string {
|
||||
if (!CG_API_KEY) return url;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}x_cg_demo_api_key=${CG_API_KEY}`;
|
||||
}
|
||||
|
||||
async function cgFetch(url: string): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(cgUrl(url), {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
console.warn("[price-feed] CoinGecko rate limited, waiting 60s...");
|
||||
await new Promise((r) => setTimeout(r, 60000));
|
||||
const retry = await fetch(url, {
|
||||
const retry = await fetch(cgUrl(url), {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -702,6 +702,21 @@ routes.get("/api/safe/:address/all-balances", async (c) => {
|
|||
if (chainBalances.length > 0) {
|
||||
const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true });
|
||||
results.push({ chainId, chainName: info.name, balances: enriched });
|
||||
} else {
|
||||
// Safe exists on this chain (API returned 200) but has zero balance.
|
||||
// Check if there are any historical transactions (>$0 activity).
|
||||
try {
|
||||
const txRes = await fetch(
|
||||
`${safeApiBase(info.prefix)}/safes/${address}/all-transactions/?limit=1&executed=true`,
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
if (txRes.ok) {
|
||||
const txData = await txRes.json() as { count?: number; results?: any[] };
|
||||
if (txData.results && txData.results.length > 0) {
|
||||
results.push({ chainId, chainName: info.name, balances: [] });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
|
|
|
|||
Loading…
Reference in New Issue