417 lines
16 KiB
TypeScript
417 lines
16 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 { 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
|
|
- 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":
|
|
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 };
|