rspace-online/server/mi-routes.ts

531 lines
21 KiB
TypeScript

/**
* 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 { loadCommunity, getDocumentData } from "./community-store";
import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth";
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
import type { MiAction } from "../lib/mi-actions";
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
import { getRecentNotesForMI } from "../modules/rnotes/mod";
import { getRecentTasksForMI } from "../modules/rtasks/mod";
import { runAgenticLoop } from "./mi-agent";
import { generateImage, generateVideoViaFal } from "./mi-media";
import { queryModuleContent } from "./mi-data-queries";
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 verifyToken(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 space's enabled modules ──
let enabledModuleIds: string[] | null = null;
if (space) {
await loadCommunity(space);
const spaceDoc = getDocumentData(space);
enabledModuleIds = spaceDoc?.meta?.enabledModules ?? null;
}
// ── 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 allModuleInfo = getModuleInfoList();
const filteredModuleInfo = enabledModuleIds
? allModuleInfo.filter(m => m.id === "rspace" || enabledModuleIds!.includes(m.id))
: allModuleInfo;
const moduleList = filteredModuleInfo
.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 capabilityModuleIds = enabledModuleIds
? Object.keys(MODULE_ROUTES).filter(id => enabledModuleIds!.includes(id))
: Object.keys(MODULE_ROUTES);
const moduleCapabilities = buildModuleCapabilities(capabilityModuleIds);
// 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",
};
// ── Build time + calendar context ──
const now = new Date();
const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const timeContext = `${weekdays[now.getUTCDay()]}, ${now.toISOString().split("T")[0]} at ${now.toISOString().split("T")[1].split(".")[0]} UTC`;
let calendarContext = "";
let notesContext = "";
let tasksContext = "";
if (space) {
const upcoming = getUpcomingEventsForMI(space);
if (upcoming.length > 0) {
const lines = upcoming.map((e) => {
const date = e.allDay ? e.start.split("T")[0] : e.start;
let line = `- ${date}: ${e.title}`;
if (e.location) line += ` (${e.location})`;
else if (e.isVirtual) line += ` (virtual)`;
if (e.tags?.length) line += ` [${e.tags.join(", ")}]`;
return line;
});
calendarContext = `\n- Upcoming events (next 14 days):\n${lines.join("\n")}`;
}
const recentNotes = getRecentNotesForMI(space, 3);
if (recentNotes.length > 0) {
const lines = recentNotes.map((n) =>
`- "${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}`
);
notesContext = `\n- Recent notes:\n${lines.join("\n")}`;
}
const openTasks = getRecentTasksForMI(space, 5);
if (openTasks.length > 0) {
const lines = openTasks.map((t) =>
`- "${t.title}" [${t.status}]${t.priority ? ` (${t.priority})` : ""}${t.description ? `: ${t.description.slice(0, 80)}` : ""}`
);
tasksContext = `\n- Open tasks:\n${lines.join("\n")}`;
}
}
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.
## Current Date & Time
${timeContext}
## 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}
- rsocials: create campaign (opens Campaign Wizard with pre-filled brief)
When the user asks to create a social media campaign, use create-content with module rsocials, contentType campaign, body.rawBrief set to the user's description, body.navigateToWizard true.
## Current Context
${contextSection}${calendarContext}${notesContext}${tasksContext}
## 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/rspace"}]
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...]}]
## Media Generation (server-side — MI will execute these and return the result URL)
When the user asks you to generate an image or video, use these actions:
[MI_ACTION:{"type":"generate-image","prompt":"a forest of glowing mushrooms at dusk","style":"illustration","ref":"$1"}]
[MI_ACTION:{"type":"generate-video","prompt":"timelapse of mycelium growing through soil","ref":"$2"}]
After the server generates the media, you will receive the URL in a follow-up message.
Then create a canvas shape referencing that URL:
[MI_ACTION:{"type":"create-shape","tagName":"folk-image-gen","props":{"src":"<the returned URL>"},"ref":"$3"}]
Available styles: illustration, photorealistic, painting, sketch, punk-zine.
## Content Queries (server-side — MI will fetch and return results)
When you need to look up the user's actual data (notes, tasks, events):
[MI_ACTION:{"type":"query-content","module":"rnotes","queryType":"recent","limit":5}]
[MI_ACTION:{"type":"query-content","module":"rtasks","queryType":"recent","limit":5}]
[MI_ACTION:{"type":"query-content","module":"rcal","queryType":"recent","limit":5}]
queryType can be: "recent", "summary", or "count".
Results will be provided in a follow-up message for you to incorporate into your response.
## 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 body = runAgenticLoop({
messages: miMessages,
provider: providerInfo.provider,
providerModel: providerInfo.providerModel,
space: space || "",
maxTurns: 5,
});
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, filteredModuleInfo);
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
- Image URLs (.png, .jpg, .gif, .webp, .svg) → folk-image (set src prop)
- Simple links / URLs (not embeddable video/interactive) → folk-bookmark (set url prop)
- Embeddable URLs (YouTube, Twitter, Google Maps, Gather, etc.) → 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-image", "folk-bookmark",
"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":
case "generate-image":
case "generate-video":
return "member";
case "navigate":
case "query-content":
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 verifyToken(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 });
});
// ── POST /execute-server-action — client-side fallback for server actions ──
mi.post("/execute-server-action", async (c) => {
const { action, space } = await c.req.json();
if (!action?.type) return c.json({ error: "action required" }, 400);
switch (action.type) {
case "generate-image": {
const result = await generateImage(action.prompt, action.style);
return c.json(result);
}
case "generate-video": {
const result = await generateVideoViaFal(action.prompt, action.source_image);
return c.json(result);
}
case "query-content": {
const result = queryModuleContent(space || "", action.module, action.queryType, action.limit);
return c.json(result);
}
default:
return c.json({ ok: false, error: `Unknown server action: ${action.type}` }, 400);
}
});
// ── 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 };