diff --git a/lib/mi-action-executor.ts b/lib/mi-action-executor.ts index e416fa9..e5d4f9a 100644 --- a/lib/mi-action-executor.ts +++ b/lib/mi-action-executor.ts @@ -1,19 +1,27 @@ /** - * MiActionExecutor — Executes parsed MI actions against the live canvas. + * MiActionExecutor — Executes parsed MI actions against the live canvas + * and module APIs. * - * Relies on `window.__canvasApi` exposed by canvas.html, which provides: - * { newShape, findFreePosition, SHAPE_DEFAULTS, setupShapeEventListeners, sync, canvasContent } + * Canvas actions use `window.__canvasApi` exposed by canvas.html. + * Module actions (create-content, etc.) make authenticated fetch calls to + * the module's API endpoints via the routing map in mi-module-routes.ts. */ import type { MiAction } from "./mi-actions"; +import { resolveModuleRoute } from "./mi-module-routes"; export interface ExecutionResult { action: MiAction; ok: boolean; shapeId?: string; + /** ID returned by module APIs (e.g. created event ID) */ + contentId?: string; error?: string; } +/** Progress callback for scaffold/batch execution. */ +export type ProgressCallback = (current: number, total: number, label: string) => void; + interface CanvasApi { newShape: (tagName: string, props?: Record) => any; findFreePosition: (width: number, height: number) => { x: number; y: number }; @@ -37,24 +45,29 @@ function resolveRef(id: string, refMap: Map): string { export class MiActionExecutor { static instance: MiActionExecutor | null = null; + #space = ""; + #token = ""; constructor() { if (MiActionExecutor.instance) return MiActionExecutor.instance; MiActionExecutor.instance = this; } - execute(actions: MiAction[]): ExecutionResult[] { - const api = getCanvasApi(); - if (!api) { - return actions.map((a) => ({ action: a, ok: false, error: "Canvas API not available" })); - } + /** Set the current space and auth token for module API calls. */ + setContext(space: string, token: string): void { + this.#space = space; + this.#token = token; + } + execute(actions: MiAction[], onProgress?: ProgressCallback): ExecutionResult[] { const results: ExecutionResult[] = []; const refMap = new Map(); - for (const action of actions) { + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + onProgress?.(i + 1, actions.length, this.#actionLabel(action)); try { - const result = this.#executeOne(action, api, refMap); + const result = this.#executeOne(action, refMap); results.push(result); } catch (e: any) { results.push({ action, ok: false, error: e.message }); @@ -63,7 +76,104 @@ export class MiActionExecutor { return results; } - #executeOne(action: MiAction, api: CanvasApi, refMap: Map): ExecutionResult { + /** Execute actions that may include async module API calls. */ + async executeAsync(actions: MiAction[], onProgress?: ProgressCallback): Promise { + const results: ExecutionResult[] = []; + const refMap = new Map(); + + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + onProgress?.(i + 1, actions.length, this.#actionLabel(action)); + try { + const result = await this.#executeOneAsync(action, refMap); + results.push(result); + } catch (e: any) { + results.push({ action, ok: false, error: e.message }); + } + } + return results; + } + + #actionLabel(action: MiAction): string { + switch (action.type) { + case "create-shape": return `Creating ${action.tagName}`; + case "create-content": return `Creating ${action.contentType} in ${action.module}`; + case "scaffold": return `Running "${action.name}"`; + case "batch": return `Executing batch`; + default: return action.type; + } + } + + #executeOne(action: MiAction, refMap: Map): ExecutionResult { + switch (action.type) { + case "create-shape": + case "update-shape": + case "delete-shape": + case "connect": + case "move-shape": + case "transform": + case "navigate": + return this.#executeCanvasAction(action, refMap); + + // Module/composite actions need async — return immediately with a note + case "create-content": + case "update-content": + case "delete-content": + case "enable-module": + case "disable-module": + case "configure-module": + case "scaffold": + case "batch": + return { action, ok: false, error: "Use executeAsync() for module actions" }; + + default: + return { action, ok: false, error: "Unknown action type" }; + } + } + + async #executeOneAsync(action: MiAction, refMap: Map): Promise { + switch (action.type) { + // Canvas actions are sync + case "create-shape": + case "update-shape": + case "delete-shape": + case "connect": + case "move-shape": + case "transform": + case "navigate": + return this.#executeCanvasAction(action, refMap); + + // Module content actions + case "create-content": + return this.#executeCreateContent(action, refMap); + case "update-content": + return this.#executeUpdateContent(action); + case "delete-content": + return this.#executeDeleteContent(action); + + // Admin actions + case "enable-module": + case "disable-module": + case "configure-module": + return { action, ok: false, error: "Admin actions not yet implemented" }; + + // Composite + case "scaffold": + return this.#executeScaffold(action, refMap); + case "batch": + return this.#executeBatch(action, refMap); + + default: + return { action, ok: false, error: "Unknown action type" }; + } + } + + #executeCanvasAction(action: MiAction, refMap: Map): ExecutionResult { + const api = getCanvasApi(); + if (!api) { + return { action, ok: false, error: "Canvas API not available" }; + } + switch (action.type) { case "create-shape": { const shape = api.newShape(action.tagName, action.props || {}); @@ -126,7 +236,6 @@ export class MiActionExecutor { } case "transform": { - // Delegate to mi-selection-transforms if available const transforms = (window as any).__miSelectionTransforms; if (transforms && transforms[action.transform]) { const shapeEls = action.shapeIds @@ -136,7 +245,6 @@ export class MiActionExecutor { return { action, ok: false, error: "No matching shapes found" }; } transforms[action.transform](shapeEls); - // Persist positions for (const el of shapeEls) { api.sync.updateShape?.(el.id); } @@ -151,7 +259,140 @@ export class MiActionExecutor { } default: - return { action, ok: false, error: `Unknown action type` }; + return { action, ok: false, error: "Unknown canvas action" }; } } + + // ── Module content actions ── + + async #executeCreateContent( + action: Extract, + refMap: Map, + ): Promise { + const route = resolveModuleRoute(action.module, action.contentType, this.#space); + if (!route) { + return { action, ok: false, error: `No route for ${action.module}/${action.contentType}` }; + } + + try { + const res = await fetch(route.url, { + method: route.method, + headers: { + "Content-Type": "application/json", + ...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}), + }, + body: JSON.stringify(action.body), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Request failed" })); + return { action, ok: false, error: err.error || `HTTP ${res.status}` }; + } + + const data = await res.json().catch(() => ({})); + const contentId = data.id || data._id || undefined; + if (action.ref && contentId) { + refMap.set(action.ref, contentId); + } + return { action, ok: true, contentId }; + } catch (e: any) { + return { action, ok: false, error: e.message }; + } + } + + async #executeUpdateContent( + action: Extract, + ): Promise { + const route = resolveModuleRoute(action.module, action.contentType, this.#space); + if (!route) { + return { action, ok: false, error: `No route for ${action.module}/${action.contentType}` }; + } + + try { + const url = `${route.url}/${encodeURIComponent(action.id)}`; + const res = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}), + }, + body: JSON.stringify(action.body), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Request failed" })); + return { action, ok: false, error: err.error || `HTTP ${res.status}` }; + } + return { action, ok: true, contentId: action.id }; + } catch (e: any) { + return { action, ok: false, error: e.message }; + } + } + + async #executeDeleteContent( + action: Extract, + ): Promise { + const route = resolveModuleRoute(action.module, action.contentType, this.#space); + if (!route) { + return { action, ok: false, error: `No route for ${action.module}/${action.contentType}` }; + } + + try { + const url = `${route.url}/${encodeURIComponent(action.id)}`; + const res = await fetch(url, { + method: "DELETE", + headers: { + ...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}), + }, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Request failed" })); + return { action, ok: false, error: err.error || `HTTP ${res.status}` }; + } + return { action, ok: true, contentId: action.id }; + } catch (e: any) { + return { action, ok: false, error: e.message }; + } + } + + // ── Composite actions ── + + async #executeScaffold( + action: Extract, + refMap: Map, + ): Promise { + const stepResults: ExecutionResult[] = []; + for (const step of action.steps) { + const result = await this.#executeOneAsync(step, refMap); + stepResults.push(result); + if (!result.ok) { + return { + action, + ok: false, + error: `Scaffold "${action.name}" failed at step: ${result.error}`, + }; + } + } + return { action, ok: true }; + } + + async #executeBatch( + action: Extract, + refMap: Map, + ): Promise { + const results: ExecutionResult[] = []; + for (const sub of action.actions) { + results.push(await this.#executeOneAsync(sub, refMap)); + } + const failures = results.filter((r) => !r.ok); + if (failures.length) { + return { + action, + ok: false, + error: `${failures.length}/${results.length} actions failed`, + }; + } + return { action, ok: true }; + } } diff --git a/lib/mi-actions.ts b/lib/mi-actions.ts index e515df1..8e5c7bc 100644 --- a/lib/mi-actions.ts +++ b/lib/mi-actions.ts @@ -7,17 +7,25 @@ */ export type MiAction = + // Canvas shape actions (existing) | { type: "create-shape"; tagName: string; props: Record; ref?: string } | { type: "update-shape"; shapeId: string; fields: Record } | { type: "delete-shape"; shapeId: string } | { type: "connect"; sourceId: string; targetId: string; color?: string } | { type: "move-shape"; shapeId: string; x: number; y: number } | { type: "navigate"; path: string } - | { - type: "transform"; - transform: string; - shapeIds: string[]; - }; + | { type: "transform"; transform: string; shapeIds: string[] } + // Module content actions + | { type: "create-content"; module: string; contentType: string; body: Record; ref?: string } + | { type: "update-content"; module: string; contentType: string; id: string; body: Record } + | { type: "delete-content"; module: string; contentType: string; id: string } + // Admin actions + | { type: "enable-module"; moduleId: string } + | { type: "disable-module"; moduleId: string } + | { type: "configure-module"; moduleId: string; settings: Record } + // Composite actions + | { type: "scaffold"; name: string; steps: MiAction[] } + | { type: "batch"; actions: MiAction[]; requireConfirm?: boolean }; export interface ParsedMiResponse { displayText: string; @@ -50,6 +58,22 @@ export function parseMiActions(text: string): ParsedMiResponse { }; } +/** Check if an action requires user confirmation before executing. */ +export function isDestructiveAction(action: MiAction): boolean { + switch (action.type) { + case "delete-shape": + case "delete-content": + case "disable-module": + return true; + case "batch": + return action.requireConfirm || action.actions.some(isDestructiveAction); + case "scaffold": + return action.steps.some(isDestructiveAction); + default: + return false; + } +} + /** Summarise executed actions for a confirmation chip. */ export function summariseActions(actions: MiAction[]): string { const counts: Record = {}; @@ -64,5 +88,49 @@ export function summariseActions(actions: MiAction[]): string { if (counts["move-shape"]) parts.push(`Moved ${counts["move-shape"]} shape(s)`); if (counts["transform"]) parts.push(`Applied ${counts["transform"]} transform(s)`); if (counts["navigate"]) parts.push(`Navigating`); + if (counts["create-content"]) parts.push(`Created ${counts["create-content"]} item(s)`); + if (counts["update-content"]) parts.push(`Updated ${counts["update-content"]} item(s)`); + if (counts["delete-content"]) parts.push(`Deleted ${counts["delete-content"]} item(s)`); + if (counts["enable-module"]) parts.push(`Enabled ${counts["enable-module"]} module(s)`); + if (counts["disable-module"]) parts.push(`Disabled ${counts["disable-module"]} module(s)`); + if (counts["configure-module"]) parts.push(`Configured ${counts["configure-module"]} module(s)`); + if (counts["scaffold"]) parts.push(`Scaffolded ${counts["scaffold"]} setup(s)`); + if (counts["batch"]) parts.push(`Batch: ${counts["batch"]} group(s)`); return parts.join(", ") || ""; } + +/** Produce a detailed list of actions for the expandable summary. */ +export function detailedActionSummary(actions: MiAction[]): string[] { + const details: string[] = []; + for (const a of actions) { + switch (a.type) { + case "create-shape": + details.push(`Created ${a.tagName}${a.props?.title ? `: "${a.props.title}"` : ""}`); + break; + case "create-content": + details.push(`Created ${a.contentType} in ${a.module}${a.body?.title ? `: "${a.body.title}"` : ""}`); + break; + case "delete-shape": + details.push(`Deleted shape ${a.shapeId}`); + break; + case "delete-content": + details.push(`Deleted ${a.contentType} ${a.id} from ${a.module}`); + break; + case "connect": + details.push(`Connected ${a.sourceId} → ${a.targetId}`); + break; + case "scaffold": + details.push(`Scaffold "${a.name}": ${a.steps.length} steps`); + break; + case "batch": + details.push(`Batch: ${a.actions.length} actions`); + break; + case "navigate": + details.push(`Navigate to ${a.path}`); + break; + default: + details.push(`${a.type}`); + } + } + return details; +} diff --git a/lib/mi-module-routes.ts b/lib/mi-module-routes.ts new file mode 100644 index 0000000..f7fbe58 --- /dev/null +++ b/lib/mi-module-routes.ts @@ -0,0 +1,85 @@ +/** + * MI Module Routes — maps module content types to API endpoints. + * + * Used by the action executor to route `create-content` / `update-content` / + * `delete-content` actions to the correct module API path. Stable abstraction + * layer between LLM output and actual HTTP calls. + */ + +export interface ModuleRouteEntry { + method: string; + /** Path template — `:space` is replaced at execution time. */ + path: string; +} + +/** Content-type → route mapping per module. */ +export const MODULE_ROUTES: Record> = { + rcal: { + event: { method: "POST", path: "/:space/rcal/api/events" }, + }, + rtasks: { + task: { method: "POST", path: "/:space/rtasks/api/tasks" }, + }, + rnotes: { + notebook: { method: "POST", path: "/:space/rnotes/api/notebooks" }, + note: { method: "POST", path: "/:space/rnotes/api/notes" }, + }, + rforum: { + thread: { method: "POST", path: "/:space/rforum/api/threads" }, + post: { method: "POST", path: "/:space/rforum/api/posts" }, + }, + rflows: { + flow: { method: "POST", path: "/:space/rflows/api/flows" }, + }, + rvote: { + proposal: { method: "POST", path: "/:space/rvote/api/proposals" }, + }, + rchoices: { + choice: { method: "POST", path: "/:space/rchoices/api/choices" }, + }, + rfiles: { + file: { method: "POST", path: "/:space/rfiles/api/files" }, + }, + rphotos: { + album: { method: "POST", path: "/:space/rphotos/api/albums" }, + }, + rdata: { + dataset: { method: "POST", path: "/:space/rdata/api/datasets" }, + }, +}; + +/** + * Resolve a module + contentType to the actual API endpoint path. + * Returns null if the route is not known. + */ +export function resolveModuleRoute( + module: string, + contentType: string, + space: string, +): { method: string; url: string } | null { + const routes = MODULE_ROUTES[module]; + if (!routes) return null; + + const entry = routes[contentType]; + if (!entry) return null; + + return { + method: entry.method, + url: entry.path.replace(":space", encodeURIComponent(space)), + }; +} + +/** + * Build a human-readable capabilities summary for a set of enabled modules. + * Used in the MI system prompt so the LLM knows what it can create. + */ +export function buildModuleCapabilities(enabledModuleIds: string[]): string { + const lines: string[] = []; + for (const modId of enabledModuleIds) { + const routes = MODULE_ROUTES[modId]; + if (!routes) continue; + const types = Object.keys(routes).join(", "); + lines.push(`- ${modId}: create ${types}`); + } + return lines.length ? lines.join("\n") : "No module APIs available."; +} diff --git a/lib/mi-tool-schema.ts b/lib/mi-tool-schema.ts index 38199d1..e2f27de 100644 --- a/lib/mi-tool-schema.ts +++ b/lib/mi-tool-schema.ts @@ -1,6 +1,7 @@ /** - * MI Tool Schema — lightweight registry of canvas shape types with keyword - * matching, so MI can suggest relevant tools as clickable chips. + * MI Tool Schema — lightweight registry of canvas shape types and module + * content types with keyword matching, so MI can suggest relevant tools + * as clickable chips. */ export interface ToolHint { @@ -8,6 +9,8 @@ export interface ToolHint { label: string; icon: string; keywords: string[]; + /** If set, this is a module action rather than a canvas shape. */ + moduleAction?: { module: string; contentType: string }; } const TOOL_HINTS: ToolHint[] = [ @@ -35,6 +38,12 @@ const TOOL_HINTS: ToolHint[] = [ { tagName: "folk-choice-rank", label: "Ranking", icon: "📊", keywords: ["rank", "order", "priority", "sort"] }, { tagName: "folk-choice-spider", label: "Spider Chart", icon: "🕸️", keywords: ["spider", "radar", "criteria", "evaluate"] }, { tagName: "folk-spider-3d", label: "3D Spider", icon: "📊", keywords: ["spider", "radar", "3d", "overlap", "membrane", "governance", "permeability"] }, + // Module content hints (these create content in rApps, not canvas shapes) + { tagName: "rcal-event", label: "Calendar Event", icon: "📅", keywords: ["event", "meeting", "schedule", "standup", "appointment"], moduleAction: { module: "rcal", contentType: "event" } }, + { tagName: "rtasks-task", label: "Task", icon: "✅", keywords: ["task", "todo", "assign", "deadline", "backlog"], moduleAction: { module: "rtasks", contentType: "task" } }, + { tagName: "rnotes-notebook", label: "Notebook", icon: "📓", keywords: ["notebook", "journal", "documentation"], moduleAction: { module: "rnotes", contentType: "notebook" } }, + { tagName: "rforum-thread", label: "Forum Thread", icon: "💬", keywords: ["thread", "discussion", "forum", "topic"], moduleAction: { module: "rforum", contentType: "thread" } }, + { tagName: "rvote-proposal", label: "Proposal", icon: "🗳️", keywords: ["proposal", "governance", "decision"], moduleAction: { module: "rvote", contentType: "proposal" } }, ]; /** diff --git a/server/index.ts b/server/index.ts index 3f2f82b..6f1e565 100644 --- a/server/index.ts +++ b/server/index.ts @@ -86,6 +86,7 @@ import { notificationRouter } from "./notification-routes"; import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service"; import { SystemClock } from "./clock-service"; import type { ClockPayload } from "./clock-service"; +import { miRoutes } from "./mi-routes"; // Register modules registerModule(canvasModule); @@ -233,6 +234,142 @@ app.get("/api/link-preview", async (c) => { } }); +// ── Ecosystem manifest (self-declaration) ── +// Serves rSpace's own manifest so other ecosystem apps can discover it +app.get("/.well-known/rspace-manifest.json", (c) => { + const modules = getModuleInfoList(); + const shapes = modules.map((m) => ({ + tagName: `folk-rapp`, + name: m.name, + description: m.description || "", + defaults: { width: 500, height: 400 }, + portDescriptors: [], + eventDescriptors: [], + config: { moduleId: m.id }, + })); + + return c.json( + { + appId: "rspace", + name: "rSpace", + version: "1.0.0", + icon: "🌐", + description: "Local-first workspace for the r-ecosystem", + homepage: "https://rspace.online", + moduleUrl: "/assets/folk-rapp.js", + color: "#6366f1", + embeddingModes: ["trusted", "sandboxed"], + shapes, + minProtocolVersion: 1, + }, + 200, + { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + } + ); +}); + +// ── Ecosystem manifest proxy (CORS avoidance) ── +// Fetches external app manifests server-side so canvas clients avoid CORS issues +const ecosystemManifestCache = new Map(); + +app.get("/api/ecosystem/:appId/manifest", async (c) => { + const appId = c.req.param("appId"); + + // Known ecosystem app origins + const ECOSYSTEM_ORIGINS: Record = { + rwallet: "https://rwallet.online", + rvote: "https://rvote.online", + rmaps: "https://rmaps.online", + rsocials: "https://rsocials.online", + }; + + const origin = ECOSYSTEM_ORIGINS[appId]; + if (!origin) { + return c.json({ error: "Unknown ecosystem app" }, 404); + } + + // Check cache (1 hour TTL) + const cached = ecosystemManifestCache.get(appId); + if (cached && Date.now() - cached.fetchedAt < 3600_000) { + return c.json(cached.data, 200, { "Cache-Control": "public, max-age=3600" }); + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const manifestUrl = `${origin}/.well-known/rspace-manifest.json`; + const res = await fetch(manifestUrl, { + headers: { "Accept": "application/json", "User-Agent": "rSpace-Ecosystem/1.0" }, + signal: controller.signal, + redirect: "follow", + }); + clearTimeout(timeout); + + if (!res.ok) return c.json({ error: "Failed to fetch manifest" }, 502); + const data = await res.json(); + + // Resolve relative moduleUrl to absolute + if (data.moduleUrl && !data.moduleUrl.startsWith("http")) { + data.resolvedModuleUrl = `${origin}${data.moduleUrl.startsWith("/") ? "" : "/"}${data.moduleUrl}`; + } else { + data.resolvedModuleUrl = data.moduleUrl; + } + data.origin = origin; + data.fetchedAt = Date.now(); + + ecosystemManifestCache.set(appId, { data, fetchedAt: Date.now() }); + + // Cap cache size + if (ecosystemManifestCache.size > 50) { + const oldest = [...ecosystemManifestCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); + ecosystemManifestCache.delete(oldest[0][0]); + } + + return c.json(data, 200, { "Cache-Control": "public, max-age=3600" }); + } catch { + return c.json({ error: "Failed to fetch manifest" }, 502); + } +}); + +// ── Ecosystem module proxy (CORS avoidance for JS modules) ── +app.get("/api/ecosystem/:appId/module", async (c) => { + const appId = c.req.param("appId"); + + // Fetch manifest first to get module URL + const cached = ecosystemManifestCache.get(appId); + if (!cached) { + return c.json({ error: "Fetch manifest first via /api/ecosystem/:appId/manifest" }, 400); + } + const manifest = cached.data as Record; + const moduleUrl = (manifest.resolvedModuleUrl || manifest.moduleUrl) as string; + if (!moduleUrl) return c.json({ error: "No moduleUrl in manifest" }, 400); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + const res = await fetch(moduleUrl, { + headers: { "Accept": "application/javascript", "User-Agent": "rSpace-Ecosystem/1.0" }, + signal: controller.signal, + redirect: "follow", + }); + clearTimeout(timeout); + + if (!res.ok) return c.json({ error: "Failed to fetch module" }, 502); + const js = await res.text(); + return new Response(js, { + headers: { + "Content-Type": "application/javascript", + "Cache-Control": "public, max-age=86400", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch { + return c.json({ error: "Failed to fetch module" }, 502); + } +}); + // ── Space registry API ── app.route("/api/spaces", spaces); @@ -245,274 +382,9 @@ app.route("/api/oauth", oauthRouter); // ── Notifications API ── app.route("/api/notifications", notificationRouter); -// ── mi — AI assistant endpoint ── -const MI_MODEL = process.env.MI_MODEL || "llama3.2"; -const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; +// ── MI — AI assistant endpoints ── +app.route("/api/mi", miRoutes); -app.post("/api/mi/ask", async (c) => { - const { query, messages = [], space, module: currentModule, context = {} } = await c.req.json(); - if (!query) return c.json({ error: "Query required" }, 400); - - // Build rApp context for the system prompt - const moduleList = getModuleInfoList() - .map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`) - .join("\n"); - - // Build extended context section from client-provided context - let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`; - if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`; - if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`; - if (context.openShapes?.length) { - const shapeSummary = context.openShapes - .slice(0, 15) - .map((s: any) => { - let desc = ` - ${s.type} (id: ${s.id})`; - if (s.title) desc += `: ${s.title}`; - if (s.snippet) desc += ` — "${s.snippet}"`; - if (s.x != null) desc += ` at (${s.x}, ${s.y})`; - return desc; - }) - .join("\n"); - contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`; - } - if (context.selectedShapes?.length) { - const selSummary = context.selectedShapes - .map((s: any) => ` - ${s.type} (id: ${s.id})${s.title ? `: ${s.title}` : ""}${s.snippet ? ` — "${s.snippet}"` : ""}`) - .join("\n"); - contextSection += `\n- The user currently has selected:\n${selSummary}`; - } - if (context.connections?.length) { - const connSummary = context.connections - .slice(0, 15) - .map((c: any) => ` - ${c.sourceId} → ${c.targetId}`) - .join("\n"); - contextSection += `\n- Connected shapes:\n${connSummary}`; - } - if (context.viewport) { - contextSection += `\n- Viewport: zoom ${context.viewport.scale?.toFixed?.(2) || context.viewport.scale}, pan (${Math.round(context.viewport.x)}, ${Math.round(context.viewport.y)})`; - } - if (context.shapeGroups?.length) { - contextSection += `\n- ${context.shapeGroups.length} group(s) of connected shapes`; - } - if (context.shapeCountByType && Object.keys(context.shapeCountByType).length) { - const typeCounts = Object.entries(context.shapeCountByType) - .map(([t, n]) => `${t}: ${n}`) - .join(", "); - contextSection += `\n- Shape types: ${typeCounts}`; - } - - const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. -You help users navigate, understand, and get the most out of the platform's apps (rApps). -You understand the full context of what the user has open and can guide them through setup and usage. - -## Available rApps -${moduleList} - -## Current Context -${contextSection} - -## Guidelines -- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. -- When suggesting actions, reference specific rApps by name and explain how they connect. -- You can suggest navigating to /:space/:moduleId paths. -- If the user has shapes open on their canvas, you can reference them by id and suggest connections. -- Help with setup: guide users through creating spaces, adding content, configuring rApps. -- If you don't know something specific about the user's data, say so honestly. -- Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform. - -## Actions -When the user asks you to create, modify, delete, connect, move, or arrange shapes on the canvas, -include action markers in your response. Each marker is on its own line: -[MI_ACTION:{"type":"create-shape","tagName":"folk-markdown","props":{"content":"# Hello"},"ref":"$1"}] -[MI_ACTION:{"type":"connect","sourceId":"$1","targetId":"shape-123"}] -[MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}] -[MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}] -[MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}] -[MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}] - -Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions. -Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt, -folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block, -folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed. - -## Transforms -When the user asks to align, distribute, or arrange selected shapes: -[MI_ACTION:{"type":"transform","transform":"align-left","shapeIds":["shape-1","shape-2"]}] -Available transforms: align-left, align-right, align-center-h, align-top, align-bottom, align-center-v, -distribute-h, distribute-v, arrange-row, arrange-column, arrange-grid, arrange-circle, -match-width, match-height, match-size.`; - - // Build conversation for Ollama - const ollamaMessages = [ - { role: "system", content: systemPrompt }, - ...messages.slice(-8).map((m: any) => ({ role: m.role, content: m.content })), - { role: "user", content: query }, - ]; - - try { - const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: MI_MODEL, messages: ollamaMessages, stream: true }), - }); - - if (!ollamaRes.ok) { - const errText = await ollamaRes.text().catch(() => ""); - console.error("mi: Ollama error:", ollamaRes.status, errText); - return c.json({ error: "AI service unavailable" }, 502); - } - - // Stream Ollama's NDJSON response directly to client - return new Response(ollamaRes.body, { - headers: { - "Content-Type": "application/x-ndjson", - "Cache-Control": "no-cache", - "Transfer-Encoding": "chunked", - }, - }); - } catch (e: any) { - console.error("mi: Failed to reach Ollama:", e.message); - // Fallback: return a static helpful response - const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList()); - return c.json({ response: fallback }); - } -}); - -// ── MI Content Triage — analyze pasted/dropped content and propose shapes ── - -const TRIAGE_SYSTEM_PROMPT = `You are a content triage engine for rSpace, a spatial canvas platform. -Given raw unstructured content (pasted text, meeting notes, link dumps, etc.), -analyze it and classify each distinct piece into the most appropriate canvas shape type. - -## Shape Mapping Rules -- URLs / links → folk-embed (set url prop) -- Dates / events / schedules → folk-calendar (set title, description props) -- Locations / addresses / places → folk-map (set query prop) -- Action items / TODOs / tasks → folk-workflow-block (set label, blockType:"action" props) -- Social media content / posts → folk-social-post (set content prop) -- Decisions / polls / questions for voting → folk-choice-vote (set question prop) -- Everything else (prose, notes, transcripts, summaries) → folk-markdown (set content prop in markdown format) - -## Output Format -Return a JSON object with: -- "summary": one-sentence overview of the content dump -- "shapes": array of { "tagName": string, "label": string, "props": object, "snippet": string (first ~80 chars of source content) } -- "connections": array of { "fromIndex": number, "toIndex": number, "reason": string } for semantic links between shapes - -## Rules -- Maximum 10 shapes per triage -- Each shape must have a unique "label" (short, descriptive title) -- props must match the shape's expected attributes -- For folk-markdown content, format nicely with headers and bullet points -- For folk-embed, extract the exact URL into props.url -- Identify connections between related items (e.g., a note references an action item, a URL is the source for a summary) -- If the content is too short or trivial for multiple shapes, still return at least one shape`; - -const KNOWN_TRIAGE_SHAPES = new Set([ - "folk-markdown", "folk-embed", "folk-calendar", "folk-map", - "folk-workflow-block", "folk-social-post", "folk-choice-vote", - "folk-prompt", "folk-image-gen", "folk-slide", -]); - -function sanitizeTriageResponse(raw: any): { shapes: any[]; connections: any[]; summary: string } { - const summary = typeof raw.summary === "string" ? raw.summary : "Content analyzed"; - let shapes = Array.isArray(raw.shapes) ? raw.shapes : []; - let connections = Array.isArray(raw.connections) ? raw.connections : []; - - // Validate and cap shapes - shapes = shapes.slice(0, 10).filter((s: any) => { - if (!s.tagName || typeof s.tagName !== "string") return false; - if (!KNOWN_TRIAGE_SHAPES.has(s.tagName)) { - s.tagName = "folk-markdown"; // fallback unknown types to markdown - } - if (!s.label) s.label = "Untitled"; - if (!s.props || typeof s.props !== "object") s.props = {}; - if (!s.snippet) s.snippet = ""; - return true; - }); - - // Validate connections — indices must reference valid shapes - connections = connections.filter((c: any) => { - return ( - typeof c.fromIndex === "number" && - typeof c.toIndex === "number" && - c.fromIndex >= 0 && - c.fromIndex < shapes.length && - c.toIndex >= 0 && - c.toIndex < shapes.length && - c.fromIndex !== c.toIndex - ); - }); - - return { shapes, connections, summary }; -} - -app.post("/api/mi/triage", async (c) => { - const { content, contentType = "paste" } = await c.req.json(); - if (!content || typeof content !== "string") { - return c.json({ error: "content required" }, 400); - } - if (!GEMINI_API_KEY) { - return c.json({ error: "GEMINI_API_KEY not configured" }, 503); - } - - // Truncate very long content - const truncated = content.length > 50000; - const trimmed = truncated ? content.slice(0, 50000) : content; - - try { - const { GoogleGenerativeAI } = await import("@google/generative-ai"); - const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); - const model = genAI.getGenerativeModel({ - model: "gemini-2.5-flash", - generationConfig: { - responseMimeType: "application/json", - } as any, - }); - - const userPrompt = `Analyze the following ${contentType === "drop" ? "dropped" : "pasted"} content and classify each piece into canvas shapes:\n\n---\n${trimmed}\n---${truncated ? "\n\n(Content was truncated at 50k characters)" : ""}`; - - const result = await model.generateContent({ - contents: [{ role: "user", parts: [{ text: userPrompt }] }], - systemInstruction: { role: "user", parts: [{ text: TRIAGE_SYSTEM_PROMPT }] }, - }); - - const text = result.response.text(); - const parsed = JSON.parse(text); - const sanitized = sanitizeTriageResponse(parsed); - - return c.json(sanitized); - } catch (e: any) { - console.error("[mi/triage] Error:", e.message); - return c.json({ error: "Triage analysis failed" }, 502); - } -}); - -function generateFallbackResponse( - query: string, - currentModule: string, - space: string, - modules: ReturnType, -): string { - const q = query.toLowerCase(); - - // Simple keyword matching for common questions - for (const m of modules) { - if (q.includes(m.id) || q.includes(m.name.toLowerCase())) { - return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${space || "personal"}/${m.id}.`; - } - } - - if (q.includes("help") || q.includes("what can")) { - return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFlows** (community funding), and **rVote** (governance). What would you like to explore?`; - } - - if (q.includes("search") || q.includes("find")) { - return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`; - } - - return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`; -} // ── Existing /api/communities/* routes (backward compatible) ── @@ -723,6 +595,7 @@ app.post("/api/x402-test", async (c) => { const FAL_KEY = process.env.FAL_KEY || ""; const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; +const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; // ── Image helpers ── diff --git a/server/mi-provider.ts b/server/mi-provider.ts new file mode 100644 index 0000000..9c56fd3 --- /dev/null +++ b/server/mi-provider.ts @@ -0,0 +1,278 @@ +/** + * MI Provider Abstraction — swappable AI backends for Mycelial Intelligence. + * + * Normalises Ollama, Gemini, Anthropic, and OpenAI-compatible endpoints into + * a single stream interface. The registry auto-detects available providers + * from environment variables at startup. + */ + +export interface MiMessage { + role: "system" | "user" | "assistant"; + content: string; + images?: string[]; +} + +export interface MiStreamChunk { + content: string; + done: boolean; +} + +export interface MiModelConfig { + /** Unique ID used in API requests (e.g. "gemini-flash") */ + id: string; + /** Provider key (e.g. "ollama", "gemini") */ + provider: string; + /** Model name as the provider expects it (e.g. "gemini-2.5-flash") */ + providerModel: string; + /** Display label for UI */ + label: string; + /** Group header in model selector */ + group: string; +} + +export interface MiProvider { + id: string; + isAvailable(): boolean; + stream(messages: MiMessage[], model: string): AsyncGenerator; +} + +// ── Model Registry ── + +const MODEL_REGISTRY: MiModelConfig[] = [ + { id: "gemini-flash", provider: "gemini", providerModel: "gemini-2.5-flash", label: "Gemini Flash", group: "Gemini" }, + { id: "gemini-pro", provider: "gemini", providerModel: "gemini-2.5-pro", label: "Gemini Pro", group: "Gemini" }, + { id: "llama3.2", provider: "ollama", providerModel: "llama3.2:3b", label: "Llama 3.2 (3B)", group: "Local" }, + { id: "llama3.1", provider: "ollama", providerModel: "llama3.1:8b", label: "Llama 3.1 (8B)", group: "Local" }, + { id: "qwen2.5-coder", provider: "ollama", providerModel: "qwen2.5-coder:7b", label: "Qwen Coder", group: "Local" }, + { id: "mistral-small", provider: "ollama", providerModel: "mistral-small:24b", label: "Mistral Small", group: "Local" }, +]; + +// ── Ollama Provider ── + +class OllamaProvider implements MiProvider { + id = "ollama"; + #url: string; + + constructor() { + this.#url = process.env.OLLAMA_URL || "http://localhost:11434"; + } + + isAvailable(): boolean { + return true; // Ollama is always "available" — fails at call time if offline + } + + async *stream(messages: MiMessage[], model: string): AsyncGenerator { + const res = await fetch(`${this.#url}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, messages, stream: true }), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`Ollama error ${res.status}: ${errText}`); + } + + if (!res.body) throw new Error("No response body from Ollama"); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + yield { + content: data.message?.content || "", + done: !!data.done, + }; + } catch { + // skip malformed lines + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + yield { content: data.message?.content || "", done: true }; + } catch { + // ignore + } + } + } +} + +// ── Gemini Provider ── + +class GeminiProvider implements MiProvider { + id = "gemini"; + + isAvailable(): boolean { + return !!(process.env.GEMINI_API_KEY); + } + + async *stream(messages: MiMessage[], model: string): AsyncGenerator { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) throw new Error("GEMINI_API_KEY not configured"); + + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(apiKey); + + // Extract system instruction from messages + const systemMsg = messages.find((m) => m.role === "system"); + const chatMessages = messages.filter((m) => m.role !== "system"); + + const geminiModel = genAI.getGenerativeModel({ + model, + ...(systemMsg ? { systemInstruction: { role: "user", parts: [{ text: systemMsg.content }] } } : {}), + }); + + // Convert messages to Gemini format + const history = chatMessages.slice(0, -1).map((m) => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })); + + const lastMsg = chatMessages[chatMessages.length - 1]; + if (!lastMsg) throw new Error("No messages to send"); + + const chat = geminiModel.startChat({ history }); + const result = await chat.sendMessageStream(lastMsg.content); + + for await (const chunk of result.stream) { + const text = chunk.text(); + if (text) { + yield { content: text, done: false }; + } + } + yield { content: "", done: true }; + } +} + +// ── Anthropic Provider (stub) ── + +class AnthropicProvider implements MiProvider { + id = "anthropic"; + + isAvailable(): boolean { + return !!(process.env.ANTHROPIC_API_KEY); + } + + async *stream(_messages: MiMessage[], _model: string): AsyncGenerator { + throw new Error("Anthropic provider not yet implemented — add @anthropic-ai/sdk"); + } +} + +// ── OpenAI-Compatible Provider (stub) ── + +class OpenAICompatProvider implements MiProvider { + id = "openai-compat"; + + isAvailable(): boolean { + return !!(process.env.OPENAI_COMPAT_URL && process.env.OPENAI_COMPAT_KEY); + } + + async *stream(_messages: MiMessage[], _model: string): AsyncGenerator { + throw new Error("OpenAI-compatible provider not yet implemented"); + } +} + +// ── Provider Registry ── + +export class MiProviderRegistry { + #providers = new Map(); + + constructor() { + this.register(new OllamaProvider()); + this.register(new GeminiProvider()); + this.register(new AnthropicProvider()); + this.register(new OpenAICompatProvider()); + } + + register(provider: MiProvider): void { + this.#providers.set(provider.id, provider); + } + + /** Look up a model by its friendly ID and return the provider + provider-model. */ + resolveModel(modelId: string): { provider: MiProvider; providerModel: string } | null { + const config = MODEL_REGISTRY.find((m) => m.id === modelId); + if (!config) return null; + + const provider = this.#providers.get(config.provider); + if (!provider || !provider.isAvailable()) return null; + + return { provider, providerModel: config.providerModel }; + } + + /** Get provider directly by provider ID (for fallback when modelId is a raw provider model name). */ + getProviderById(providerId: string): MiProvider | null { + const provider = this.#providers.get(providerId); + return provider && provider.isAvailable() ? provider : null; + } + + /** All models whose provider is currently available. */ + getAvailableModels(): MiModelConfig[] { + return MODEL_REGISTRY.filter((m) => { + const p = this.#providers.get(m.provider); + return p && p.isAvailable(); + }); + } + + /** MI_MODEL env or first available model. */ + getDefaultModel(): string { + const envModel = process.env.MI_MODEL; + if (envModel) { + // Check if it's a registry ID + if (MODEL_REGISTRY.find((m) => m.id === envModel)) return envModel; + // It might be a raw provider model name — find the registry entry + const entry = MODEL_REGISTRY.find((m) => m.providerModel === envModel); + if (entry) return entry.id; + // Unknown — return as-is for backward compat (Ollama will handle it) + return envModel; + } + // First available + const available = this.getAvailableModels(); + return available[0]?.id || "llama3.2"; + } + + /** + * Stream from any provider and normalize to NDJSON `{message:{content:"..."}}` format + * matching Ollama's wire format — zero changes needed in the client parser. + */ + streamToNDJSON(gen: AsyncGenerator): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + async pull(controller) { + try { + const { value, done: iterDone } = await gen.next(); + if (iterDone) { + controller.close(); + return; + } + const payload = JSON.stringify({ + message: { role: "assistant", content: value.content }, + done: value.done, + }); + controller.enqueue(encoder.encode(payload + "\n")); + if (value.done) controller.close(); + } catch (err: any) { + controller.error(err); + } + }, + }); + } +} + +/** Singleton registry, ready at import time. */ +export const miRegistry = new MiProviderRegistry(); diff --git a/server/mi-routes.ts b/server/mi-routes.ts new file mode 100644 index 0000000..3920ba5 --- /dev/null +++ b/server/mi-routes.ts @@ -0,0 +1,413 @@ +/** + * MI Routes — Hono sub-app for Mycelial Intelligence endpoints. + * + * POST /ask — main chat, uses provider registry + * POST /triage — content triage via Gemini + * GET /models — available models for frontend selector + * POST /validate-actions — permission check for parsed actions + */ + +import { Hono } from "hono"; +import { miRegistry } from "./mi-provider"; +import type { MiMessage } from "./mi-provider"; +import { getModuleInfoList, getAllModules } from "../shared/module"; +import { resolveCallerRole, roleAtLeast } from "./spaces"; +import type { SpaceRoleString } from "./spaces"; +import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; +import type { EncryptIDClaims } from "@encryptid/sdk/server"; +import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes"; +import type { MiAction } from "../lib/mi-actions"; + +const mi = new Hono(); + +// ── GET /models — available models for frontend selector ── + +mi.get("/models", (c) => { + const models = miRegistry.getAvailableModels(); + const defaultModel = miRegistry.getDefaultModel(); + return c.json({ models, default: defaultModel }); +}); + +// ── POST /ask — main MI chat ── + +mi.post("/ask", async (c) => { + const { query, messages = [], space, module: currentModule, context = {}, model: requestedModel } = await c.req.json(); + if (!query) return c.json({ error: "Query required" }, 400); + + // ── Resolve caller role ── + let callerRole: SpaceRoleString = "viewer"; + let claims: EncryptIDClaims | null = null; + try { + const token = extractToken(c.req.raw.headers); + if (token) { + claims = await verifyEncryptIDToken(token); + } + } catch { /* unauthenticated → viewer */ } + + if (space && claims) { + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } else if (claims) { + // Authenticated but no space context → member + callerRole = "member"; + } + + // ── Resolve model ── + const modelId = requestedModel || miRegistry.getDefaultModel(); + let providerInfo = miRegistry.resolveModel(modelId); + + // Fallback: if the modelId is a raw provider model (e.g. "llama3.2:3b"), try Ollama + if (!providerInfo) { + const ollama = miRegistry.getProviderById("ollama"); + if (ollama) { + providerInfo = { provider: ollama, providerModel: modelId }; + } + } + + if (!providerInfo) { + return c.json({ error: `Model "${modelId}" not available` }, 503); + } + + // ── Build system prompt ── + const moduleList = getModuleInfoList() + .map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`) + .join("\n"); + + // Extended context from client + let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`; + if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`; + if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`; + if (context.openShapes?.length) { + const shapeSummary = context.openShapes + .slice(0, 15) + .map((s: any) => { + let desc = ` - ${s.type} (id: ${s.id})`; + if (s.title) desc += `: ${s.title}`; + if (s.snippet) desc += ` — "${s.snippet}"`; + if (s.x != null) desc += ` at (${s.x}, ${s.y})`; + return desc; + }) + .join("\n"); + contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`; + } + if (context.selectedShapes?.length) { + const selSummary = context.selectedShapes + .map((s: any) => ` - ${s.type} (id: ${s.id})${s.title ? `: ${s.title}` : ""}${s.snippet ? ` — "${s.snippet}"` : ""}`) + .join("\n"); + contextSection += `\n- The user currently has selected:\n${selSummary}`; + } + if (context.connections?.length) { + const connSummary = context.connections + .slice(0, 15) + .map((conn: any) => ` - ${conn.sourceId} → ${conn.targetId}`) + .join("\n"); + contextSection += `\n- Connected shapes:\n${connSummary}`; + } + if (context.viewport) { + contextSection += `\n- Viewport: zoom ${context.viewport.scale?.toFixed?.(2) || context.viewport.scale}, pan (${Math.round(context.viewport.x)}, ${Math.round(context.viewport.y)})`; + } + if (context.shapeGroups?.length) { + contextSection += `\n- ${context.shapeGroups.length} group(s) of connected shapes`; + } + if (context.shapeCountByType && Object.keys(context.shapeCountByType).length) { + const typeCounts = Object.entries(context.shapeCountByType) + .map(([t, n]) => `${t}: ${n}`) + .join(", "); + contextSection += `\n- Shape types: ${typeCounts}`; + } + + // Module capabilities for enabled modules + const enabledModuleIds = Object.keys(MODULE_ROUTES); + const moduleCapabilities = buildModuleCapabilities(enabledModuleIds); + + // Role-permission mapping + const rolePermissions: Record = { + viewer: "browse, explain, navigate only", + member: "create content, shapes, connections", + moderator: "+ configure modules, moderate content, delete content", + admin: "+ enable/disable modules, manage members", + }; + + const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. +You help users navigate, understand, and get the most out of the platform's apps (rApps). +You understand the full context of what the user has open and can guide them through setup and usage. + +## Your Caller's Role: ${callerRole} in space "${space || "none"}" +- viewer: ${rolePermissions.viewer} +- member: ${rolePermissions.member} +- moderator: ${rolePermissions.moderator} +- admin: ${rolePermissions.admin} +Only suggest actions the user's role permits. The caller is a **${callerRole}**. + +## Available rApps +${moduleList} + +## Module Capabilities (content you can create via actions) +${moduleCapabilities} + +## Current Context +${contextSection} + +## Guidelines +- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. +- When suggesting actions, reference specific rApps by name and explain how they connect. +- You can suggest navigating to /:space/:moduleId paths. +- If the user has shapes open on their canvas, you can reference them by id and suggest connections. +- Help with setup: guide users through creating spaces, adding content, configuring rApps. +- If you don't know something specific about the user's data, say so honestly. +- Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform. + +## Canvas Shape Actions +When the user asks you to create, modify, delete, connect, move, or arrange shapes on the canvas, +include action markers in your response. Each marker is on its own line: +[MI_ACTION:{"type":"create-shape","tagName":"folk-markdown","props":{"content":"# Hello"},"ref":"$1"}] +[MI_ACTION:{"type":"connect","sourceId":"$1","targetId":"shape-123"}] +[MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}] +[MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}] +[MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}] +[MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}] + +Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions. +Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt, +folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block, +folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed. + +## Transforms +When the user asks to align, distribute, or arrange selected shapes: +[MI_ACTION:{"type":"transform","transform":"align-left","shapeIds":["shape-1","shape-2"]}] +Available transforms: align-left, align-right, align-center-h, align-top, align-bottom, align-center-v, +distribute-h, distribute-v, arrange-row, arrange-column, arrange-grid, arrange-circle, +match-width, match-height, match-size. + +## Module Content Actions +When the user asks to create content in a specific rApp (not a canvas shape): +[MI_ACTION:{"type":"create-content","module":"rcal","contentType":"event","body":{"title":"Friday Standup","start_time":"2026-03-13T10:00:00Z","end_time":"2026-03-13T10:30:00Z"},"ref":"$1"}] +[MI_ACTION:{"type":"create-content","module":"rtasks","contentType":"task","body":{"title":"Review docs","status":"TODO","priority":"high"}}] +[MI_ACTION:{"type":"create-content","module":"rnotes","contentType":"notebook","body":{"title":"Meeting Notes"}}] + +## Scaffolding (for complex setup — member+ only) +For multi-step setup requests like "set up this space for a book club": +[MI_ACTION:{"type":"scaffold","name":"Book Club Setup","steps":[...ordered actions...]}] + +## Batch Actions +[MI_ACTION:{"type":"batch","actions":[...actions...],"requireConfirm":true}] +Use requireConfirm:true for destructive batches.`; + + // Build conversation + const miMessages: MiMessage[] = [ + { role: "system", content: systemPrompt }, + ...messages.slice(-8).map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content })), + { role: "user", content: query }, + ]; + + try { + const gen = providerInfo.provider.stream(miMessages, providerInfo.providerModel); + const body = miRegistry.streamToNDJSON(gen); + + return new Response(body, { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + }, + }); + } catch (e: any) { + console.error("mi: Provider error:", e.message); + const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList()); + return c.json({ response: fallback }); + } +}); + +// ── POST /triage — content triage via Gemini ── + +const TRIAGE_SYSTEM_PROMPT = `You are a content triage engine for rSpace, a spatial canvas platform. +Given raw unstructured content (pasted text, meeting notes, link dumps, etc.), +analyze it and classify each distinct piece into the most appropriate canvas shape type. + +## Shape Mapping Rules +- URLs / links → folk-embed (set url prop) +- Dates / events / schedules → folk-calendar (set title, description props) +- Locations / addresses / places → folk-map (set query prop) +- Action items / TODOs / tasks → folk-workflow-block (set label, blockType:"action" props) +- Social media content / posts → folk-social-post (set content prop) +- Decisions / polls / questions for voting → folk-choice-vote (set question prop) +- Everything else (prose, notes, transcripts, summaries) → folk-markdown (set content prop in markdown format) + +## Output Format +Return a JSON object with: +- "summary": one-sentence overview of the content dump +- "shapes": array of { "tagName": string, "label": string, "props": object, "snippet": string (first ~80 chars of source content) } +- "connections": array of { "fromIndex": number, "toIndex": number, "reason": string } for semantic links between shapes + +## Rules +- Maximum 10 shapes per triage +- Each shape must have a unique "label" (short, descriptive title) +- props must match the shape's expected attributes +- For folk-markdown content, format nicely with headers and bullet points +- For folk-embed, extract the exact URL into props.url +- Identify connections between related items (e.g., a note references an action item, a URL is the source for a summary) +- If the content is too short or trivial for multiple shapes, still return at least one shape`; + +const KNOWN_TRIAGE_SHAPES = new Set([ + "folk-markdown", "folk-embed", "folk-calendar", "folk-map", + "folk-workflow-block", "folk-social-post", "folk-choice-vote", + "folk-prompt", "folk-image-gen", "folk-slide", +]); + +function sanitizeTriageResponse(raw: any): { shapes: any[]; connections: any[]; summary: string } { + const summary = typeof raw.summary === "string" ? raw.summary : "Content analyzed"; + let shapes = Array.isArray(raw.shapes) ? raw.shapes : []; + let connections = Array.isArray(raw.connections) ? raw.connections : []; + + shapes = shapes.slice(0, 10).filter((s: any) => { + if (!s.tagName || typeof s.tagName !== "string") return false; + if (!KNOWN_TRIAGE_SHAPES.has(s.tagName)) { + s.tagName = "folk-markdown"; + } + if (!s.label) s.label = "Untitled"; + if (!s.props || typeof s.props !== "object") s.props = {}; + if (!s.snippet) s.snippet = ""; + return true; + }); + + connections = connections.filter((conn: any) => { + return ( + typeof conn.fromIndex === "number" && + typeof conn.toIndex === "number" && + conn.fromIndex >= 0 && + conn.fromIndex < shapes.length && + conn.toIndex >= 0 && + conn.toIndex < shapes.length && + conn.fromIndex !== conn.toIndex + ); + }); + + return { shapes, connections, summary }; +} + +mi.post("/triage", async (c) => { + const { content, contentType = "paste" } = await c.req.json(); + if (!content || typeof content !== "string") { + return c.json({ error: "content required" }, 400); + } + + const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; + if (!GEMINI_API_KEY) { + return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + } + + const truncated = content.length > 50000; + const trimmed = truncated ? content.slice(0, 50000) : content; + + try { + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { + responseMimeType: "application/json", + } as any, + }); + + const userPrompt = `Analyze the following ${contentType === "drop" ? "dropped" : "pasted"} content and classify each piece into canvas shapes:\n\n---\n${trimmed}\n---${truncated ? "\n\n(Content was truncated at 50k characters)" : ""}`; + + const result = await model.generateContent({ + contents: [{ role: "user", parts: [{ text: userPrompt }] }], + systemInstruction: { role: "user", parts: [{ text: TRIAGE_SYSTEM_PROMPT }] }, + }); + + const text = result.response.text(); + const parsed = JSON.parse(text); + const sanitized = sanitizeTriageResponse(parsed); + + return c.json(sanitized); + } catch (e: any) { + console.error("[mi/triage] Error:", e.message); + return c.json({ error: "Triage analysis failed" }, 502); + } +}); + +// ── POST /validate-actions — permission check ── + +function getRequiredRole(action: MiAction): SpaceRoleString { + switch (action.type) { + case "enable-module": + case "disable-module": + case "configure-module": + return "admin"; + case "delete-shape": + case "delete-content": + return "moderator"; + case "create-shape": + case "create-content": + case "update-shape": + case "update-content": + case "connect": + case "move-shape": + case "transform": + case "scaffold": + case "batch": + return "member"; + case "navigate": + return "viewer"; + default: + return "member"; + } +} + +mi.post("/validate-actions", async (c) => { + const { actions, space } = await c.req.json() as { actions: MiAction[]; space: string }; + if (!actions?.length) return c.json({ validated: [] }); + + // Resolve caller role + let callerRole: SpaceRoleString = "viewer"; + try { + const token = extractToken(c.req.raw.headers); + if (token) { + const claims = await verifyEncryptIDToken(token); + if (space && claims) { + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } else if (claims) { + callerRole = "member"; + } + } + } catch { /* viewer */ } + + const validated = actions.map((action) => { + const requiredRole = getRequiredRole(action); + const allowed = roleAtLeast(callerRole, requiredRole); + return { action, allowed, requiredRole }; + }); + + return c.json({ validated, callerRole }); +}); + +// ── Fallback response (when AI is unavailable) ── + +function generateFallbackResponse( + query: string, + currentModule: string, + space: string, + modules: ReturnType, +): string { + const q = query.toLowerCase(); + + for (const m of modules) { + if (q.includes(m.id) || q.includes(m.name.toLowerCase())) { + return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${space || "personal"}/${m.id}.`; + } + } + + if (q.includes("help") || q.includes("what can")) { + return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFlows** (community funding), and **rVote** (governance). What would you like to explore?`; + } + + if (q.includes("search") || q.includes("find")) { + return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`; + } + + return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`; +} + +export { mi as miRoutes }; diff --git a/shared/components/rstack-mi.ts b/shared/components/rstack-mi.ts index 79e7c78..10f648d 100644 --- a/shared/components/rstack-mi.ts +++ b/shared/components/rstack-mi.ts @@ -1,13 +1,13 @@ /** - * — AI-powered assistant embedded in the rSpace header. + * — AI-powered community builder 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. + * Rich builder panel with interchangeable AI backend, model selector, + * action confirmation, scaffold progress, and role-based UI adaptation. */ import { getAccessToken } from "./rstack-identity"; -import { parseMiActions, summariseActions } from "../../lib/mi-actions"; +import { parseMiActions, summariseActions, isDestructiveAction, detailedActionSummary } from "../../lib/mi-actions"; +import type { MiAction } from "../../lib/mi-actions"; import { MiActionExecutor } from "../../lib/mi-action-executor"; import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema"; import { SpeechDictation } from "../../lib/speech-dictation"; @@ -16,23 +16,116 @@ interface MiMessage { role: "user" | "assistant"; content: string; actionSummary?: string; + actionDetails?: string[]; toolHints?: ToolHint[]; } +interface MiModelConfig { + id: string; + provider: string; + providerModel: string; + label: string; + group: string; +} + export class RStackMi extends HTMLElement { #shadow: ShadowRoot; #messages: MiMessage[] = []; #abortController: AbortController | null = null; #dictation: SpeechDictation | null = null; #interimText = ""; + #preferredModel: string = ""; + #minimized = false; + #availableModels: MiModelConfig[] = []; + #pendingConfirm: { actions: MiAction[]; resolve: (ok: boolean) => void } | null = null; + #scaffoldProgress: { current: number; total: number; label: string } | null = null; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); + this.#preferredModel = localStorage.getItem("mi-preferred-model") || ""; } connectedCallback() { this.#render(); + this.#loadModels(); + this.#setupKeyboard(); + } + + disconnectedCallback() { + document.removeEventListener("keydown", this.#keyHandler); + } + + #keyHandler = (e: KeyboardEvent) => { + // Cmd/Ctrl+K opens MI panel + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + const panel = this.#shadow.getElementById("mi-panel"); + const bar = this.#shadow.getElementById("mi-bar"); + const pill = this.#shadow.getElementById("mi-pill"); + if (this.#minimized) { + this.#minimized = false; + panel?.classList.add("open"); + panel?.classList.remove("hidden"); + bar?.classList.add("focused"); + pill?.classList.remove("visible"); + } else { + panel?.classList.add("open"); + bar?.classList.add("focused"); + } + const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null; + input?.focus(); + } + }; + + #setupKeyboard() { + document.addEventListener("keydown", this.#keyHandler); + } + + async #loadModels() { + try { + const res = await fetch("/api/mi/models"); + if (res.ok) { + const data = await res.json(); + this.#availableModels = data.models || []; + if (!this.#preferredModel && data.default) { + this.#preferredModel = data.default; + } + this.#renderModelSelector(); + } + } catch { /* offline — use whatever's there */ } + } + + #renderModelSelector() { + const select = this.#shadow.getElementById("mi-model-select") as HTMLSelectElement | null; + if (!select || !this.#availableModels.length) return; + + // Group models + const groups = new Map(); + for (const m of this.#availableModels) { + const group = groups.get(m.group) || []; + group.push(m); + groups.set(m.group, group); + } + + select.innerHTML = ""; + for (const [group, models] of groups) { + const optgroup = document.createElement("optgroup"); + optgroup.label = group; + for (const m of models) { + const opt = document.createElement("option"); + opt.value = m.id; + opt.textContent = m.label; + if (m.id === this.#preferredModel) opt.selected = true; + optgroup.appendChild(opt); + } + select.appendChild(optgroup); + } + + select.addEventListener("change", () => { + this.#preferredModel = select.value; + localStorage.setItem("mi-preferred-model", select.value); + }); } #render() { @@ -41,54 +134,156 @@ export class RStackMi extends HTMLElement {
- + ${SpeechDictation.isSupported() ? '' : ''}
+
+ + mi + +
+ + +

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.

+

I can create content across all rApps, set up spaces, connect knowledge, and help you build.

+ + +
+ + +
+
+
+ mi
`; - const input = this.#shadow.getElementById("mi-input") as HTMLInputElement; + const barInput = this.#shadow.getElementById("mi-bar-input") as HTMLInputElement; + const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement; const panel = this.#shadow.getElementById("mi-panel")!; const bar = this.#shadow.getElementById("mi-bar")!; + const pill = this.#shadow.getElementById("mi-pill")!; + const sendBtn = this.#shadow.getElementById("mi-send")!; - input.addEventListener("focus", () => { + // Bar input opens the panel + barInput.addEventListener("focus", () => { panel.classList.add("open"); bar.classList.add("focused"); + // Transfer any text to the panel input + if (barInput.value.trim()) { + input.value = barInput.value; + barInput.value = ""; + } + setTimeout(() => input.focus(), 50); }); + barInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && barInput.value.trim()) { + e.preventDefault(); + panel.classList.add("open"); + bar.classList.add("focused"); + input.value = barInput.value; + barInput.value = ""; + setTimeout(() => { + input.focus(); + this.#ask(input.value.trim()); + input.value = ""; + this.#autoResize(input); + }, 50); + } + }); + + // Panel textarea: Enter sends, Shift+Enter for newline input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && input.value.trim()) { + if (e.key === "Enter" && !e.shiftKey && input.value.trim()) { + e.preventDefault(); this.#ask(input.value.trim()); input.value = ""; + this.#autoResize(input); } if (e.key === "Escape") { - panel.classList.remove("open"); - bar.classList.remove("focused"); - input.blur(); + this.#minimize(); } }); + input.addEventListener("input", () => this.#autoResize(input)); + + sendBtn.addEventListener("click", () => { + if (input.value.trim()) { + this.#ask(input.value.trim()); + input.value = ""; + this.#autoResize(input); + } + }); + + // Close panel on outside click document.addEventListener("click", (e) => { - if (!this.contains(e.target as Node)) { + if (!this.contains(e.target as Node) && !pill.contains(e.target as Node)) { panel.classList.remove("open"); bar.classList.remove("focused"); } }); - // Stop clicks inside the panel from closing it + // Prevent internal clicks from closing panel.addEventListener("click", (e) => e.stopPropagation()); bar.addEventListener("click", (e) => e.stopPropagation()); + // Minimize button + this.#shadow.getElementById("mi-minimize")!.addEventListener("click", () => this.#minimize()); + + // Close button + this.#shadow.getElementById("mi-close")!.addEventListener("click", () => { + panel.classList.remove("open"); + bar.classList.remove("focused"); + }); + + // Pill click restores + pill.addEventListener("click", (e) => { + e.stopPropagation(); + this.#minimized = false; + pill.classList.remove("visible"); + panel.classList.add("open"); + panel.classList.remove("hidden"); + bar.classList.add("focused"); + input.focus(); + }); + + // Confirmation buttons + this.#shadow.getElementById("mi-confirm-allow")!.addEventListener("click", () => { + if (this.#pendingConfirm) { + this.#pendingConfirm.resolve(true); + this.#pendingConfirm = null; + this.#shadow.getElementById("mi-confirm")!.style.display = "none"; + } + }); + this.#shadow.getElementById("mi-confirm-cancel")!.addEventListener("click", () => { + if (this.#pendingConfirm) { + this.#pendingConfirm.resolve(false); + this.#pendingConfirm = null; + this.#shadow.getElementById("mi-confirm")!.style.display = "none"; + } + }); + // Voice dictation const micBtn = this.#shadow.getElementById("mi-mic") as HTMLButtonElement | null; if (micBtn) { @@ -96,17 +291,17 @@ export class RStackMi extends HTMLElement { this.#dictation = new SpeechDictation({ onInterim: (text) => { this.#interimText = text; - input.value = baseText + (baseText ? " " : "") + text; + barInput.value = baseText + (baseText ? " " : "") + text; }, onFinal: (text) => { this.#interimText = ""; baseText += (baseText ? " " : "") + text; - input.value = baseText; + barInput.value = baseText; }, onStateChange: (recording) => { micBtn.classList.toggle("recording", recording); if (!recording) { - baseText = input.value; + baseText = barInput.value; this.#interimText = ""; } }, @@ -116,23 +311,37 @@ export class RStackMi extends HTMLElement { micBtn.addEventListener("click", (e) => { e.stopPropagation(); if (!this.#dictation!.isRecording) { - baseText = input.value; + baseText = barInput.value; } this.#dictation!.toggle(); - input.focus(); + barInput.focus(); }); } } + #minimize() { + this.#minimized = true; + const panel = this.#shadow.getElementById("mi-panel")!; + const pill = this.#shadow.getElementById("mi-pill")!; + panel.classList.remove("open"); + panel.classList.add("hidden"); + this.#shadow.getElementById("mi-bar")!.classList.remove("focused"); + pill.classList.add("visible"); + } + + #autoResize(textarea: HTMLTextAreaElement) { + textarea.style.height = "auto"; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px"; + } + /** 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) + // Deep canvas context from MI bridge const bridge = (window as any).__miCanvasBridge; if (bridge) { const cc = bridge.getCanvasContext(); @@ -161,7 +370,6 @@ export class RStackMi extends HTMLElement { 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] @@ -177,14 +385,10 @@ export class RStackMi extends HTMLElement { } } - // Active tab/layer info const tabBar = document.querySelector("rstack-tab-bar"); if (tabBar) { - const active = tabBar.getAttribute("active") || ""; - ctx.activeTab = active; + ctx.activeTab = tabBar.getAttribute("active") || ""; } - - // Page title ctx.pageTitle = document.title; return ctx; @@ -225,6 +429,7 @@ export class RStackMi extends HTMLElement { space: context.space, module: context.module, context, + model: this.#preferredModel || undefined, }), signal: this.#abortController.signal, }); @@ -250,7 +455,6 @@ export class RStackMi extends HTMLElement { if (data.message?.content) { this.#messages[assistantIdx].content += data.message.content; } - // Non-streaming fallback if (data.response) { this.#messages[assistantIdx].content = data.response; } @@ -259,20 +463,19 @@ export class RStackMi extends HTMLElement { 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 + // Parse and execute MI actions 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); + await this.#executeWithConfirmation(actions, context); this.#messages[assistantIdx].actionSummary = summariseActions(actions); + this.#messages[assistantIdx].actionDetails = detailedActionSummary(actions); } // Check for tool suggestions @@ -292,6 +495,102 @@ export class RStackMi extends HTMLElement { } } + async #executeWithConfirmation(actions: MiAction[], context: Record) { + const needsConfirm = actions.some(isDestructiveAction); + + if (needsConfirm) { + const descriptions = actions + .filter(isDestructiveAction) + .map((a) => { + switch (a.type) { + case "delete-shape": return `Delete shape ${a.shapeId}`; + case "delete-content": return `Delete ${a.contentType} from ${a.module}`; + case "disable-module": return `Disable ${a.moduleId}`; + case "batch": return `Batch with ${a.actions.length} actions`; + default: return a.type; + } + }); + + const confirmEl = this.#shadow.getElementById("mi-confirm")!; + const textEl = this.#shadow.getElementById("mi-confirm-text")!; + textEl.textContent = `MI wants to: ${descriptions.join(", ")}`; + confirmEl.style.display = "flex"; + + const allowed = await new Promise((resolve) => { + this.#pendingConfirm = { actions, resolve }; + }); + + if (!allowed) return; + } + + // Validate permissions + const token = getAccessToken(); + try { + const valRes = await fetch("/api/mi/validate-actions", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ actions, space: context.space }), + }); + + if (valRes.ok) { + const { validated } = await valRes.json(); + const blocked = validated.filter((v: any) => !v.allowed); + if (blocked.length) { + // Show blocked actions in the message + const blockedMsg = blocked + .map((v: any) => `🔒 "${v.action.type}" requires ${v.requiredRole} role`) + .join("\n"); + this.#messages.push({ role: "assistant", content: blockedMsg }); + this.#renderMessages(this.#shadow.getElementById("mi-messages")!); + + // Only execute allowed actions + const allowedActions = validated + .filter((v: any) => v.allowed) + .map((v: any) => v.action); + if (!allowedActions.length) return; + return this.#runActions(allowedActions, context); + } + } + } catch { /* if validation fails, proceed anyway — module APIs have their own guards */ } + + await this.#runActions(actions, context); + } + + async #runActions(actions: MiAction[], context: Record) { + const executor = new MiActionExecutor(); + const token = getAccessToken() || ""; + executor.setContext(context.space || "", token); + + // Check if any actions need async execution + const hasModuleActions = actions.some((a) => + ["create-content", "update-content", "delete-content", "scaffold", "batch"].includes(a.type), + ); + + const progressEl = this.#shadow.getElementById("mi-scaffold-progress")!; + const fillEl = this.#shadow.getElementById("mi-scaffold-fill")!; + const labelEl = this.#shadow.getElementById("mi-scaffold-label")!; + + const onProgress = (current: number, total: number, label: string) => { + this.#scaffoldProgress = { current, total, label }; + progressEl.style.display = "flex"; + fillEl.style.width = `${(current / total) * 100}%`; + labelEl.textContent = `${label} (${current}/${total})`; + }; + + if (hasModuleActions) { + await executor.executeAsync(actions, onProgress); + } else { + executor.execute(actions, onProgress); + } + + // Hide progress + progressEl.style.display = "none"; + this.#scaffoldProgress = null; + } + #renderMessages(container: HTMLElement) { container.innerHTML = this.#messages .map( @@ -299,7 +598,7 @@ export class RStackMi extends HTMLElement {
${m.role === "user" ? "You" : "✧ mi"}
${m.content ? this.#formatContent(m.content) : ''}
- ${m.actionSummary ? `
${this.#escapeHtml(m.actionSummary)}
` : ""} + ${m.actionSummary ? `
${this.#escapeHtml(m.actionSummary)}
${(m.actionDetails || []).map((d) => `
→ ${this.#escapeHtml(d)}
`).join("")}
` : ""} ${m.toolHints?.length ? `
${m.toolHints.map((h) => ``).join("")}
` : ""}
`, @@ -324,14 +623,34 @@ export class RStackMi extends HTMLElement { } #formatContent(s: string): string { - // Escape HTML then convert markdown-like formatting - return s + let escaped = s .replace(/&/g, "&") .replace(//g, ">") - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/`(.+?)`/g, '$1') - .replace(/\n/g, "
"); + .replace(/>/g, ">"); + + // Code blocks (```) + escaped = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, + '
$2
'); + + // Headers + escaped = escaped.replace(/^### (.+)$/gm, '$1'); + escaped = escaped.replace(/^## (.+)$/gm, '$1'); + escaped = escaped.replace(/^# (.+)$/gm, '$1'); + + // Bold and inline code + escaped = escaped.replace(/\*\*(.+?)\*\*/g, "$1"); + escaped = escaped.replace(/`(.+?)`/g, '$1'); + + // Links + escaped = escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Lists (- item) + escaped = escaped.replace(/^- (.+)$/gm, '• $1'); + + // Newlines + escaped = escaped.replace(/\n/g, "
"); + + return escaped; } static define(tag = "rstack-mi") { @@ -344,6 +663,7 @@ const STYLES = ` .mi { position: relative; flex: 1; max-width: 480px; min-width: 0; } +/* ── Search bar in header ── */ .mi-bar { display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 10px; @@ -358,13 +678,13 @@ const STYLES = ` -webkit-background-clip: text; -webkit-text-fill-color: transparent; } -.mi-input { +.mi-input-bar { flex: 1; border: none; outline: none; background: none; font-size: 0.85rem; min-width: 0; font-family: inherit; color: var(--rs-text-primary); } -.mi-input::placeholder { color: var(--rs-text-muted); } +.mi-input-bar::placeholder { color: var(--rs-text-muted); } .mi-mic-btn { background: none; border: none; cursor: pointer; padding: 2px 4px; @@ -381,20 +701,53 @@ const STYLES = ` 50% { transform: scale(1.15); } } +/* ── Rich panel ── */ .mi-panel { - position: absolute; top: calc(100% + 8px); left: 0; right: 0; - min-width: 360px; max-height: 420px; + position: fixed; top: 56px; right: 16px; + width: 520px; max-width: calc(100vw - 32px); + height: 65vh; max-height: calc(100vh - 72px); min-height: 300px; border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,0.3); display: none; z-index: 300; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); + resize: vertical; } .mi-panel.open { display: flex; flex-direction: column; } +.mi-panel.hidden { display: none; } +/* ── Panel header ── */ +.mi-panel-header { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; border-bottom: 1px solid var(--rs-border); + flex-shrink: 0; +} +.mi-panel-icon { + font-size: 1rem; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.mi-panel-title { + font-weight: 700; font-size: 0.85rem; + color: var(--rs-text-primary); +} +.mi-model-select { + font-size: 0.75rem; padding: 3px 6px; border-radius: 6px; + border: 1px solid var(--rs-border); background: var(--rs-btn-secondary-bg); + color: var(--rs-text-primary); font-family: inherit; cursor: pointer; + max-width: 150px; +} +.mi-panel-spacer { flex: 1; } +.mi-panel-btn { + background: none; border: none; cursor: pointer; + font-size: 1.1rem; line-height: 1; padding: 2px 6px; border-radius: 4px; + color: var(--rs-text-muted); transition: all 0.15s; +} +.mi-panel-btn:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); } + +/* ── Messages ── */ .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; } @@ -420,12 +773,26 @@ const STYLES = ` } .mi-msg--user .mi-msg-body { color: var(--rs-text-primary); } +/* ── Markdown rendering ── */ .mi-code { padding: 1px 5px; border-radius: 4px; font-size: 0.8rem; font-family: 'SF Mono', Monaco, Consolas, monospace; background: var(--rs-btn-secondary-bg); color: #0ea5e9; } +.mi-codeblock { + background: var(--rs-btn-secondary-bg); border-radius: 8px; + padding: 10px 12px; margin: 6px 0; overflow-x: auto; + font-size: 0.78rem; line-height: 1.5; + font-family: 'SF Mono', Monaco, Consolas, monospace; +} +.mi-codeblock code { color: var(--rs-text-primary); } +.mi-h1 { font-size: 1.1rem; display: block; margin: 8px 0 4px; color: var(--rs-text-primary); } +.mi-h2 { font-size: 1rem; display: block; margin: 6px 0 3px; color: var(--rs-text-primary); } +.mi-h3 { font-size: 0.9rem; display: block; margin: 4px 0 2px; color: var(--rs-text-primary); } +.mi-link { color: #06b6d4; text-decoration: underline; } +.mi-list-item { display: block; padding-left: 4px; } +/* ── Typing indicator ── */ .mi-typing { display: inline-flex; gap: 4px; padding: 4px 0; } .mi-typing span { width: 6px; height: 6px; border-radius: 50%; @@ -439,10 +806,19 @@ const STYLES = ` 30% { transform: translateY(-4px); } } +/* ── Action chips (collapsible) ── */ +.mi-action-details { margin-top: 6px; } .mi-action-chip { - display: inline-block; margin-top: 6px; padding: 3px 10px; + display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: rgba(6,182,212,0.12); color: #06b6d4; + cursor: pointer; list-style: none; +} +.mi-action-chip::marker { display: none; content: ''; } +.mi-action-chip::-webkit-details-marker { display: none; } +.mi-action-list { padding: 6px 0 0 6px; } +.mi-action-item { + font-size: 0.75rem; color: var(--rs-text-muted); padding: 1px 0; } .mi-tool-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } @@ -454,8 +830,87 @@ const STYLES = ` } .mi-tool-chip:hover { background: var(--rs-bg-hover); } +/* ── Confirmation bar ── */ +.mi-confirm { + display: flex; align-items: center; gap: 8px; + padding: 8px 14px; background: rgba(234,179,8,0.1); + border-top: 1px solid rgba(234,179,8,0.3); flex-shrink: 0; +} +.mi-confirm-icon { font-size: 1rem; } +.mi-confirm-text { flex: 1; font-size: 0.8rem; color: var(--rs-text-primary); } +.mi-confirm-btns { display: flex; gap: 6px; } +.mi-confirm-allow { + padding: 4px 12px; border-radius: 6px; border: none; cursor: pointer; + font-size: 0.75rem; font-weight: 600; font-family: inherit; + background: #06b6d4; color: white; +} +.mi-confirm-cancel { + padding: 4px 12px; border-radius: 6px; border: none; cursor: pointer; + font-size: 0.75rem; font-weight: 600; font-family: inherit; + background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary); +} + +/* ── Scaffold progress ── */ +.mi-scaffold-progress { + display: flex; align-items: center; gap: 8px; + padding: 8px 14px; border-top: 1px solid var(--rs-border); flex-shrink: 0; +} +.mi-scaffold-bar { + flex: 1; height: 4px; border-radius: 2px; + background: var(--rs-btn-secondary-bg); overflow: hidden; +} +.mi-scaffold-fill { + height: 100%; border-radius: 2px; transition: width 0.3s; + background: linear-gradient(90deg, #06b6d4, #7c3aed); +} +.mi-scaffold-label { + font-size: 0.75rem; color: var(--rs-text-muted); white-space: nowrap; +} + +/* ── Input area ── */ +.mi-input-area { + display: flex; align-items: flex-end; gap: 8px; + padding: 10px 14px; border-top: 1px solid var(--rs-border); + flex-shrink: 0; +} +.mi-input { + flex: 1; border: none; outline: none; background: none; + font-size: 0.85rem; min-width: 0; resize: none; + font-family: inherit; + color: var(--rs-text-primary); + max-height: 120px; line-height: 1.4; +} +.mi-input::placeholder { color: var(--rs-text-muted); } +.mi-send-btn { + background: none; border: none; cursor: pointer; + font-size: 0.9rem; padding: 4px 8px; border-radius: 6px; + color: #06b6d4; transition: all 0.15s; flex-shrink: 0; +} +.mi-send-btn:hover { background: rgba(6,182,212,0.12); } + +/* ── Minimized pill ── */ +.mi-pill { + position: fixed; bottom: 16px; right: 16px; + padding: 6px 14px; border-radius: 20px; + font-size: 0.8rem; font-weight: 600; cursor: pointer; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border); + box-shadow: 0 4px 16px rgba(0,0,0,0.2); z-index: 300; + display: none; align-items: center; gap: 6px; + color: var(--rs-text-primary); transition: all 0.2s; +} +.mi-pill:hover { box-shadow: 0 4px 20px rgba(6,182,212,0.3); } +.mi-pill.visible { display: flex; } +.mi-pill-icon { + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} + @media (max-width: 640px) { .mi { max-width: none; width: 100%; } - .mi-panel { min-width: 0; width: 100%; left: 0; right: 0; } + .mi-panel { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + width: 100%; max-width: 100%; height: 100%; max-height: 100%; + border-radius: 0; resize: none; + } } `;