feat(mi): upgrade to community-focused builder with interchangeable AI

Transform MI from a small Ollama-only chat dropdown into a rich builder
panel with swappable AI backends (Gemini, Ollama, Anthropic/OpenAI stubs),
role-based permission gating, and module-spanning content actions.

- Provider abstraction layer (mi-provider.ts) with stream normalization
- Extracted MI endpoints into mi-routes.ts Hono sub-app
- New action types: create/update/delete-content, scaffold, batch
- Module routing map (mi-module-routes.ts) for rApp API integration
- Redesigned panel: fixed 520px, model selector, textarea, minimize pill
- Action confirmation for destructive ops, scaffold progress bar
- Permission validation endpoint with role-based action gating
- Better markdown rendering (headers, code blocks, links, lists)
- Cmd/Ctrl+K keyboard shortcut, collapsible action details

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 18:29:19 -07:00
parent a93d124d4b
commit eb00579183
8 changed files with 1758 additions and 336 deletions

View File

@ -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<string, any>) => any;
findFreePosition: (width: number, height: number) => { x: number; y: number };
@ -37,24 +45,29 @@ function resolveRef(id: string, refMap: Map<string, string>): 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<string, string>();
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<string, string>): ExecutionResult {
/** Execute actions that may include async module API calls. */
async executeAsync(actions: MiAction[], onProgress?: ProgressCallback): Promise<ExecutionResult[]> {
const results: ExecutionResult[] = [];
const refMap = new Map<string, string>();
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<string, string>): 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<string, string>): Promise<ExecutionResult> {
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<string, string>): 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<MiAction, { type: "create-content" }>,
refMap: Map<string, string>,
): Promise<ExecutionResult> {
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<MiAction, { type: "update-content" }>,
): Promise<ExecutionResult> {
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<MiAction, { type: "delete-content" }>,
): Promise<ExecutionResult> {
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<MiAction, { type: "scaffold" }>,
refMap: Map<string, string>,
): Promise<ExecutionResult> {
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<MiAction, { type: "batch" }>,
refMap: Map<string, string>,
): Promise<ExecutionResult> {
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 };
}
}

View File

@ -7,17 +7,25 @@
*/
export type MiAction =
// Canvas shape actions (existing)
| { type: "create-shape"; tagName: string; props: Record<string, any>; ref?: string }
| { type: "update-shape"; shapeId: string; fields: Record<string, any> }
| { 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<string, any>; ref?: string }
| { type: "update-content"; module: string; contentType: string; id: string; body: Record<string, any> }
| { 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<string, any> }
// 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<string, number> = {};
@ -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;
}

85
lib/mi-module-routes.ts Normal file
View File

@ -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<string, Record<string, ModuleRouteEntry>> = {
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.";
}

View File

@ -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" } },
];
/**

View File

@ -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<string, { data: unknown; fetchedAt: number }>();
app.get("/api/ecosystem/:appId/manifest", async (c) => {
const appId = c.req.param("appId");
// Known ecosystem app origins
const ECOSYSTEM_ORIGINS: Record<string, string> = {
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<string, unknown>;
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<typeof getModuleInfoList>,
): 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 ──

278
server/mi-provider.ts Normal file
View File

@ -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<MiStreamChunk>;
}
// ── 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<MiStreamChunk> {
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<MiStreamChunk> {
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<MiStreamChunk> {
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<MiStreamChunk> {
throw new Error("OpenAI-compatible provider not yet implemented");
}
}
// ── Provider Registry ──
export class MiProviderRegistry {
#providers = new Map<string, MiProvider>();
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<MiStreamChunk>): 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();

413
server/mi-routes.ts Normal file
View File

@ -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<SpaceRoleString, string> = {
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<typeof getModuleInfoList>,
): 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 };

View File

@ -1,13 +1,13 @@
/**
* <rstack-mi> AI-powered assistant embedded in the rSpace header.
* <rstack-mi> 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<string, MiModelConfig[]>();
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 {
<div class="mi">
<div class="mi-bar" id="mi-bar">
<span class="mi-icon">&#10023;</span>
<input class="mi-input" id="mi-input" type="text"
placeholder="Ask mi anything — setup, navigation, what's possible..." autocomplete="off" />
<input class="mi-input-bar" id="mi-bar-input" type="text"
placeholder="Ask mi anything..." autocomplete="off" />
${SpeechDictation.isSupported() ? '<button class="mi-mic-btn" id="mi-mic" title="Voice dictation">🎤</button>' : ''}
</div>
<div class="mi-panel" id="mi-panel">
<div class="mi-panel-header">
<span class="mi-panel-icon">&#10023;</span>
<span class="mi-panel-title">mi</span>
<select class="mi-model-select" id="mi-model-select" title="Select AI model"></select>
<div class="mi-panel-spacer"></div>
<button class="mi-panel-btn" id="mi-minimize" title="Minimize (Escape)">&#8722;</button>
<button class="mi-panel-btn" id="mi-close" title="Close">&times;</button>
</div>
<div class="mi-messages" id="mi-messages">
<div class="mi-welcome">
<span class="mi-welcome-icon">&#10023;</span>
<p>Hi, I'm <strong>mi</strong> your mycelial intelligence guide.</p>
<p class="mi-welcome-sub">I see everything you have open and can help with setup, navigation, connecting rApps, or anything else.</p>
<p class="mi-welcome-sub">I can create content across all rApps, set up spaces, connect knowledge, and help you build.</p>
</div>
</div>
<div class="mi-confirm" id="mi-confirm" style="display:none;">
<span class="mi-confirm-icon"></span>
<span class="mi-confirm-text" id="mi-confirm-text"></span>
<div class="mi-confirm-btns">
<button class="mi-confirm-allow" id="mi-confirm-allow">Allow</button>
<button class="mi-confirm-cancel" id="mi-confirm-cancel">Cancel</button>
</div>
</div>
<div class="mi-scaffold-progress" id="mi-scaffold-progress" style="display:none;">
<div class="mi-scaffold-bar"><div class="mi-scaffold-fill" id="mi-scaffold-fill"></div></div>
<span class="mi-scaffold-label" id="mi-scaffold-label"></span>
</div>
<div class="mi-input-area">
<textarea class="mi-input" id="mi-input" rows="1"
placeholder="Ask mi to build, create, or explore..." autocomplete="off"></textarea>
<button class="mi-send-btn" id="mi-send" title="Send">&#9654;</button>
</div>
</div>
<div class="mi-pill" id="mi-pill">
<span class="mi-pill-icon">&#10023;</span> mi
</div>
</div>
`;
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<string, any> {
const ctx: Record<string, any> = {};
// 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<string, any>) {
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<boolean>((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<string, any>) {
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 {
<div class="mi-msg mi-msg--${m.role}">
<span class="mi-msg-who">${m.role === "user" ? "You" : "&#10023; mi"}</span>
<div class="mi-msg-body">${m.content ? this.#formatContent(m.content) : '<span class="mi-typing"><span></span><span></span><span></span></span>'}</div>
${m.actionSummary ? `<div class="mi-action-chip">${this.#escapeHtml(m.actionSummary)}</div>` : ""}
${m.actionSummary ? `<details class="mi-action-details"><summary class="mi-action-chip">${this.#escapeHtml(m.actionSummary)}</summary><div class="mi-action-list">${(m.actionDetails || []).map((d) => `<div class="mi-action-item">→ ${this.#escapeHtml(d)}</div>`).join("")}</div></details>` : ""}
${m.toolHints?.length ? `<div class="mi-tool-chips">${m.toolHints.map((h) => `<button class="mi-tool-chip" data-tag="${h.tagName}">${h.icon} ${this.#escapeHtml(h.label)}</button>`).join("")}</div>` : ""}
</div>
`,
@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`(.+?)`/g, '<code class="mi-code">$1</code>')
.replace(/\n/g, "<br>");
.replace(/>/g, "&gt;");
// Code blocks (```)
escaped = escaped.replace(/```(\w*)\n([\s\S]*?)```/g,
'<pre class="mi-codeblock"><code>$2</code></pre>');
// Headers
escaped = escaped.replace(/^### (.+)$/gm, '<strong class="mi-h3">$1</strong>');
escaped = escaped.replace(/^## (.+)$/gm, '<strong class="mi-h2">$1</strong>');
escaped = escaped.replace(/^# (.+)$/gm, '<strong class="mi-h1">$1</strong>');
// Bold and inline code
escaped = escaped.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
escaped = escaped.replace(/`(.+?)`/g, '<code class="mi-code">$1</code>');
// Links
escaped = escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" class="mi-link">$1</a>');
// Lists (- item)
escaped = escaped.replace(/^- (.+)$/gm, '<span class="mi-list-item">• $1</span>');
// Newlines
escaped = escaped.replace(/\n/g, "<br>");
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;
}
}
`;