/** * — AI-powered assistant embedded in the rSpace header. * * Renders a search input ("Ask mi anything..."). On submit, queries * /api/mi/ask and streams the response into a dropdown panel. * Supports multi-turn conversation with context. */ import { getAccessToken } from "./rstack-identity"; import { parseMiActions, summariseActions } from "../../lib/mi-actions"; import { MiActionExecutor } from "../../lib/mi-action-executor"; import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema"; interface MiMessage { role: "user" | "assistant"; content: string; actionSummary?: string; toolHints?: ToolHint[]; } export class RStackMi extends HTMLElement { #shadow: ShadowRoot; #messages: MiMessage[] = []; #abortController: AbortController | null = null; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); } #render() { this.#shadow.innerHTML = `

Hi, I'm mi — your mycelial intelligence guide.

I see everything you have open and can help with setup, navigation, connecting rApps, or anything else.

`; const input = this.#shadow.getElementById("mi-input") as HTMLInputElement; const panel = this.#shadow.getElementById("mi-panel")!; const bar = this.#shadow.getElementById("mi-bar")!; input.addEventListener("focus", () => { panel.classList.add("open"); bar.classList.add("focused"); }); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && input.value.trim()) { this.#ask(input.value.trim()); input.value = ""; } if (e.key === "Escape") { panel.classList.remove("open"); bar.classList.remove("focused"); input.blur(); } }); document.addEventListener("click", (e) => { if (!this.contains(e.target as Node)) { panel.classList.remove("open"); bar.classList.remove("focused"); } }); // Stop clicks inside the panel from closing it panel.addEventListener("click", (e) => e.stopPropagation()); bar.addEventListener("click", (e) => e.stopPropagation()); } /** Gather page context: open shapes, active module, tabs, canvas state. */ #gatherContext(): Record { const ctx: Record = {}; // Current space and module ctx.space = document.querySelector("rstack-space-switcher")?.getAttribute("current") || ""; ctx.module = document.querySelector("rstack-app-switcher")?.getAttribute("current") || ""; // Deep canvas context from MI bridge (if available) const bridge = (window as any).__miCanvasBridge; if (bridge) { const cc = bridge.getCanvasContext(); ctx.openShapes = cc.allShapes.slice(0, 20).map((s: any) => ({ type: s.type, id: s.id, x: Math.round(s.x), y: Math.round(s.y), width: Math.round(s.width), height: Math.round(s.height), ...(s.content ? { snippet: s.content.slice(0, 80) } : {}), ...(s.title ? { title: s.title } : {}), })); if (cc.selectedShapes.length) { ctx.selectedShapes = cc.selectedShapes.map((s: any) => ({ type: s.type, id: s.id, x: Math.round(s.x), y: Math.round(s.y), ...(s.content ? { snippet: s.content.slice(0, 80) } : {}), ...(s.title ? { title: s.title } : {}), })); } if (cc.connections.length) ctx.connections = cc.connections; ctx.viewport = cc.viewport; if (cc.shapeGroups.length) ctx.shapeGroups = cc.shapeGroups; ctx.shapeCountByType = cc.shapeCountByType; } else { // Fallback: basic shape list from DOM const canvasContent = document.getElementById("canvas-content"); if (canvasContent) { const shapes = [...canvasContent.children] .filter((el) => el.tagName?.includes("-") && el.id) .map((el: any) => ({ type: el.tagName.toLowerCase(), id: el.id, ...(el.content ? { snippet: el.content.slice(0, 60) } : {}), ...(el.title ? { title: el.title } : {}), })) .slice(0, 20); if (shapes.length) ctx.openShapes = shapes; } } // Active tab/layer info const tabBar = document.querySelector("rstack-tab-bar"); if (tabBar) { const active = tabBar.getAttribute("active") || ""; ctx.activeTab = active; } // Page title ctx.pageTitle = document.title; return ctx; } async #ask(query: string) { const panel = this.#shadow.getElementById("mi-panel")!; const messagesEl = this.#shadow.getElementById("mi-messages")!; panel.classList.add("open"); this.#shadow.getElementById("mi-bar")!.classList.add("focused"); // Add user message this.#messages.push({ role: "user", content: query }); this.#renderMessages(messagesEl); // Add placeholder for assistant this.#messages.push({ role: "assistant", content: "" }); const assistantIdx = this.#messages.length - 1; this.#renderMessages(messagesEl); // Abort previous this.#abortController?.abort(); this.#abortController = new AbortController(); try { const token = getAccessToken(); const context = this.#gatherContext(); const res = await fetch("/api/mi/ask", { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ query, messages: this.#messages.slice(0, -1).slice(-10), space: context.space, module: context.module, context, }), signal: this.#abortController.signal, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); throw new Error(err.error || "Request failed"); } if (!res.body) throw new Error("No response stream"); const reader = res.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); for (const line of chunk.split("\n").filter(Boolean)) { try { const data = JSON.parse(line); if (data.message?.content) { this.#messages[assistantIdx].content += data.message.content; } // Non-streaming fallback if (data.response) { this.#messages[assistantIdx].content = data.response; } } catch { /* skip malformed lines */ } } this.#renderMessages(messagesEl); } // If still empty after stream, show fallback if (!this.#messages[assistantIdx].content) { this.#messages[assistantIdx].content = "I couldn't generate a response. Please try again."; this.#renderMessages(messagesEl); } else { // Parse and execute MI actions from the response const rawText = this.#messages[assistantIdx].content; const { displayText, actions } = parseMiActions(rawText); this.#messages[assistantIdx].content = displayText; if (actions.length) { const executor = new MiActionExecutor(); executor.execute(actions); this.#messages[assistantIdx].actionSummary = summariseActions(actions); } // Check for tool suggestions const hints = suggestTools(query); if (hints.length) { this.#messages[assistantIdx].toolHints = hints; } this.#renderMessages(messagesEl); } } catch (e: any) { if (e.name !== "AbortError") { this.#messages[this.#messages.length - 1].content = "Sorry, I'm not available right now. Please try again later."; this.#renderMessages(messagesEl); } } } #renderMessages(container: HTMLElement) { container.innerHTML = this.#messages .map( (m) => `
${m.role === "user" ? "You" : "✧ mi"}
${m.content ? this.#formatContent(m.content) : ''}
${m.actionSummary ? `
${this.#escapeHtml(m.actionSummary)}
` : ""} ${m.toolHints?.length ? `
${m.toolHints.map((h) => ``).join("")}
` : ""}
`, ) .join(""); // Wire tool chip clicks container.querySelectorAll(".mi-tool-chip").forEach((btn) => { btn.addEventListener("click", () => { const tag = btn.dataset.tag; if (!tag) return; const executor = new MiActionExecutor(); executor.execute([{ type: "create-shape", tagName: tag, props: {} }]); }); }); container.scrollTop = container.scrollHeight; } #escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } #formatContent(s: string): string { // Escape HTML then convert markdown-like formatting return s .replace(/&/g, "&") .replace(//g, ">") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/`(.+?)`/g, '$1') .replace(/\n/g, "
"); } static define(tag = "rstack-mi") { if (!customElements.get(tag)) customElements.define(tag, RStackMi); } } const STYLES = ` :host { display: contents; } .mi { position: relative; flex: 1; max-width: 480px; min-width: 0; } .mi-bar { display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 10px; transition: all 0.2s; } :host-context([data-theme="dark"]) .mi-bar { background: rgba(255,255,255,0.06); } :host-context([data-theme="light"]) .mi-bar { background: rgba(0,0,0,0.04); } :host-context([data-theme="dark"]) .mi-bar.focused { background: rgba(255,255,255,0.1); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); } :host-context([data-theme="light"]) .mi-bar.focused { background: rgba(0,0,0,0.06); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); } .mi-icon { font-size: 0.9rem; flex-shrink: 0; background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .mi-input { flex: 1; border: none; outline: none; background: none; font-size: 0.85rem; min-width: 0; font-family: inherit; } :host-context([data-theme="dark"]) .mi-input { color: #e2e8f0; } :host-context([data-theme="light"]) .mi-input { color: #0f172a; } :host-context([data-theme="dark"]) .mi-input::placeholder { color: #64748b; } :host-context([data-theme="light"]) .mi-input::placeholder { color: #94a3b8; } .mi-panel { position: absolute; top: calc(100% + 8px); left: 0; right: 0; min-width: 360px; max-height: 420px; border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,0.3); display: none; z-index: 300; } .mi-panel.open { display: flex; flex-direction: column; } :host-context([data-theme="dark"]) .mi-panel { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); } :host-context([data-theme="light"]) .mi-panel { background: white; border: 1px solid rgba(0,0,0,0.1); } .mi-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; max-height: 400px; } .mi-welcome { text-align: center; padding: 24px 16px; } .mi-welcome-icon { font-size: 2rem; display: block; margin-bottom: 8px; background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } :host-context([data-theme="dark"]) .mi-welcome p { color: #e2e8f0; } :host-context([data-theme="light"]) .mi-welcome p { color: #374151; } .mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; } .mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; } .mi-msg { display: flex; flex-direction: column; gap: 4px; } .mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; } :host-context([data-theme="dark"]) .mi-msg--user .mi-msg-who { color: #06b6d4; } :host-context([data-theme="light"]) .mi-msg--user .mi-msg-who { color: #0891b2; } .mi-msg--assistant .mi-msg-who { background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .mi-msg-body { font-size: 0.85rem; line-height: 1.6; word-break: break-word; } :host-context([data-theme="dark"]) .mi-msg-body { color: #cbd5e1; } :host-context([data-theme="light"]) .mi-msg-body { color: #374151; } :host-context([data-theme="dark"]) .mi-msg--user .mi-msg-body { color: #e2e8f0; } :host-context([data-theme="light"]) .mi-msg--user .mi-msg-body { color: #0f172a; } .mi-code { padding: 1px 5px; border-radius: 4px; font-size: 0.8rem; font-family: 'SF Mono', Monaco, Consolas, monospace; } :host-context([data-theme="dark"]) .mi-code { background: rgba(255,255,255,0.08); color: #7dd3fc; } :host-context([data-theme="light"]) .mi-code { background: rgba(0,0,0,0.06); color: #0284c7; } .mi-typing { display: inline-flex; gap: 4px; padding: 4px 0; } .mi-typing span { width: 6px; height: 6px; border-radius: 50%; animation: miBounce 1.2s ease-in-out infinite; } :host-context([data-theme="dark"]) .mi-typing span { background: #64748b; } :host-context([data-theme="light"]) .mi-typing span { background: #94a3b8; } .mi-typing span:nth-child(2) { animation-delay: 0.15s; } .mi-typing span:nth-child(3) { animation-delay: 0.3s; } @keyframes miBounce { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-4px); } } .mi-action-chip { display: inline-block; margin-top: 6px; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; } :host-context([data-theme="dark"]) .mi-action-chip { background: rgba(6,182,212,0.15); color: #67e8f9; } :host-context([data-theme="light"]) .mi-action-chip { background: rgba(6,182,212,0.1); color: #0891b2; } .mi-tool-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .mi-tool-chip { padding: 4px 10px; border-radius: 8px; border: none; font-size: 0.75rem; cursor: pointer; transition: background 0.15s; font-family: inherit; } :host-context([data-theme="dark"]) .mi-tool-chip { background: rgba(255,255,255,0.08); color: #e2e8f0; } :host-context([data-theme="light"]) .mi-tool-chip { background: rgba(0,0,0,0.05); color: #374151; } :host-context([data-theme="dark"]) .mi-tool-chip:hover { background: rgba(255,255,255,0.15); } :host-context([data-theme="light"]) .mi-tool-chip:hover { background: rgba(0,0,0,0.1); } @media (max-width: 640px) { .mi { max-width: 200px; } .mi-panel { min-width: 300px; left: -60px; } } `;