965 lines
40 KiB
TypeScript
965 lines
40 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 { getRecentCampaignsForMI } from "../modules/rsocials/mod";
|
|
import { getRecentContactsForMI } from "../modules/rnetwork/mod";
|
|
import { getRecentThreadsForMI } from "../modules/rinbox/mod";
|
|
import { getRecentCommitmentsForMI } from "../modules/rtime/mod";
|
|
import { getRecentFilesForMI } from "../modules/rfiles/mod";
|
|
import { getUpcomingRemindersForMI } from "../modules/rschedule/mod";
|
|
import { getMapPinsForMI } from "../modules/rmaps/mod";
|
|
import { getRecentMeetingsForMI } from "../modules/rmeets/mod";
|
|
import { getRecentVideosForMI } from "../modules/rtube/mod";
|
|
import { getRecentMessagesForMI } from "../modules/rchats/mod";
|
|
import { getRecentPublicationsForMI } from "../modules/rpubs/mod";
|
|
import { getRecentDesignsForMI } from "../modules/rswag/mod";
|
|
import { getRecentSheetsForMI } from "../modules/rsheets/mod";
|
|
import { getLinkedDocsForMI } from "../modules/rdocs/mod";
|
|
import { getRecentSessionsForMI } from "../modules/rdesign/mod";
|
|
import { getSharedAlbumsForMI } from "../modules/rphotos/mod";
|
|
import { getRecentFlowsForMI } from "../modules/rflows/mod";
|
|
import { getRecentIntentsForMI } from "../modules/rexchange/mod";
|
|
import { getRecentOrdersForMI } from "../modules/rcart/mod";
|
|
import { getActiveProposalsForMI } from "../modules/rvote/mod";
|
|
import { getRecentBooksForMI } from "../modules/rbooks/mod";
|
|
import { getRecentSplatsForMI } from "../modules/rsplat/mod";
|
|
import { getRecentTripsForMI } from "../modules/rtrips/mod";
|
|
import { getActiveListingsForMI } from "../modules/rbnb/mod";
|
|
import { getActiveVehiclesForMI } from "../modules/rvnb/mod";
|
|
import { getForumInstancesForMI } from "../modules/rforum/mod";
|
|
import { getRecentChoiceSessionsForMI } from "../modules/rchoices/mod";
|
|
import { getActivePromptsForMI } from "../modules/crowdsurf/mod";
|
|
import { getGovShapesForMI } from "../modules/rgov/mod";
|
|
import { getCrdtTokensForMI } from "../modules/rwallet/mod";
|
|
import { getCanvasSummaryForMI } from "../modules/rspace/mod";
|
|
import { getDataSummaryForMI } from "../modules/rdata/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 = "";
|
|
let campaignsContext = "";
|
|
let contactsContext = "";
|
|
let inboxContext = "";
|
|
let commitmentsContext = "";
|
|
let filesContext = "";
|
|
let remindersContext = "";
|
|
let mapsContext = "";
|
|
let meetsContext = "";
|
|
let tubeContext = "";
|
|
let chatsContext = "";
|
|
let pubsContext = "";
|
|
let swagContext = "";
|
|
let sheetsContext = "";
|
|
let docsContext = "";
|
|
let designContext = "";
|
|
let photosContext = "";
|
|
let flowsContext = "";
|
|
let exchangeContext = "";
|
|
let cartContext = "";
|
|
let voteContext = "";
|
|
let booksContext = "";
|
|
let splatsContext = "";
|
|
let tripsContext = "";
|
|
let bnbContext = "";
|
|
let vnbContext = "";
|
|
let forumContext = "";
|
|
let choicesContext = "";
|
|
let crowdsurfContext = "";
|
|
let govContext = "";
|
|
let walletContext = "";
|
|
let canvasContext = "";
|
|
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 recentCampaigns = getRecentCampaignsForMI(space, 3);
|
|
if (recentCampaigns.length > 0) {
|
|
const lines = recentCampaigns.map((c) =>
|
|
`- "${c.title}" (${c.platforms.join(", ")}, ${c.postCount} posts)`
|
|
);
|
|
campaignsContext = `\n- Recent campaigns:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const contacts = getRecentContactsForMI(space, 3);
|
|
if (contacts.length > 0) {
|
|
const lines = contacts.map((c) =>
|
|
`- ${c.name} (${c.role})${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}`
|
|
);
|
|
contactsContext = `\n- Network contacts:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const recentThreads = getRecentThreadsForMI(space, 3);
|
|
if (recentThreads.length > 0) {
|
|
const lines = recentThreads.map((t) =>
|
|
`- "${t.subject}" from ${t.fromAddress || "unknown"} [${t.status}]${t.isRead ? "" : " (unread)"}`
|
|
);
|
|
inboxContext = `\n- Recent emails:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const commitments = getRecentCommitmentsForMI(space, 3);
|
|
if (commitments.length > 0) {
|
|
const lines = commitments.map((c) =>
|
|
`- ${c.memberName}: ${c.hours}h ${c.skill} — ${c.desc.slice(0, 80)}`
|
|
);
|
|
commitmentsContext = `\n- Active commitments:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const recentFiles = getRecentFilesForMI(space, 3);
|
|
if (recentFiles.length > 0) {
|
|
const lines = recentFiles.map((f) =>
|
|
`- ${f.title || f.originalFilename} (${f.mimeType || "unknown"})`
|
|
);
|
|
filesContext = `\n- Recent files:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const reminders = getUpcomingRemindersForMI(space, 14, 3);
|
|
if (reminders.length > 0) {
|
|
const lines = reminders.map((r) => {
|
|
const date = new Date(r.remindAt).toISOString().split("T")[0];
|
|
return `- ${date}: ${r.title}${r.sourceModule ? ` (from ${r.sourceModule})` : ""}`;
|
|
});
|
|
remindersContext = `\n- Upcoming reminders:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const pins = getMapPinsForMI(space, 5);
|
|
if (pins.length > 0) {
|
|
const lines = pins.map((p) => `- "${p.label}" (${p.type}) at ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`);
|
|
mapsContext = `\n- Map pins:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const meetings = getRecentMeetingsForMI(space, 3);
|
|
if (meetings.length > 0) {
|
|
const lines = meetings.map((m) => `- "${m.title}" (${m.participantCount} participants)`);
|
|
meetsContext = `\n- Recent meetings:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const playlists = getRecentVideosForMI(space, 3);
|
|
if (playlists.length > 0) {
|
|
const lines = playlists.map((v) => `- "${v.name}" (${v.entryCount} entries)`);
|
|
tubeContext = `\n- Playlists:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const chatMsgs = getRecentMessagesForMI(space, 3);
|
|
if (chatMsgs.length > 0) {
|
|
const lines = chatMsgs.map((m) => `- [${m.channel}] ${m.author}: ${m.content.slice(0, 80)}`);
|
|
chatsContext = `\n- Recent chats:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const pubs = getRecentPublicationsForMI(space, 3);
|
|
if (pubs.length > 0) {
|
|
const lines = pubs.map((p) => `- "${p.title}" by ${p.author} (${p.format})`);
|
|
pubsContext = `\n- Publications:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const swagDesigns = getRecentDesignsForMI(space, 3);
|
|
if (swagDesigns.length > 0) {
|
|
const lines = swagDesigns.map((d) => `- "${d.title}" (${d.productType}, ${d.status})`);
|
|
swagContext = `\n- Store designs:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const sheets = getRecentSheetsForMI(space, 3);
|
|
if (sheets.length > 0) {
|
|
const lines = sheets.map((s) => `- "${s.name}" (${s.cellCount} cells)`);
|
|
sheetsContext = `\n- Spreadsheets:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const linkedDocs = getLinkedDocsForMI(space, 3);
|
|
if (linkedDocs.length > 0) {
|
|
const lines = linkedDocs.map((d) => `- "${d.title}"`);
|
|
docsContext = `\n- Documents:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const designSessions = getRecentSessionsForMI(space, 3);
|
|
if (designSessions.length > 0) {
|
|
const lines = designSessions.map((s) => `- "${s.title}" (${s.pageCount} pages, ${s.frameCount} frames)`);
|
|
designContext = `\n- Design sessions:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const albums = getSharedAlbumsForMI(space, 3);
|
|
if (albums.length > 0) {
|
|
const lines = albums.map((a) => `- "${a.name}"`);
|
|
photosContext = `\n- Shared albums:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const flows = getRecentFlowsForMI(space, 3);
|
|
if (flows.length > 0) {
|
|
const lines = flows.map((f) => `- "${f.name}" (${f.nodeCount} nodes)`);
|
|
flowsContext = `\n- Flows:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const intents = getRecentIntentsForMI(space, 3);
|
|
if (intents.length > 0) {
|
|
const lines = intents.map((i) => `- ${i.side} ${i.tokenId} [${i.status}]`);
|
|
exchangeContext = `\n- Exchange intents:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const orders = getRecentOrdersForMI(space, 3);
|
|
if (orders.length > 0) {
|
|
const lines = orders.map((o) => `- "${o.title}" [${o.status}]`);
|
|
cartContext = `\n- Recent orders:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const proposals = getActiveProposalsForMI(space, 3);
|
|
if (proposals.length > 0) {
|
|
const lines = proposals.map((p) => `- "${p.title}" [${p.status}] (${p.voteCount} votes)`);
|
|
voteContext = `\n- Proposals:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const books = getRecentBooksForMI(space, 3);
|
|
if (books.length > 0) {
|
|
const lines = books.map((b) => `- "${b.title}" by ${b.author}`);
|
|
booksContext = `\n- Books:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const splats = getRecentSplatsForMI(space, 3);
|
|
if (splats.length > 0) {
|
|
const lines = splats.map((s) => `- "${s.title}" (${s.format})`);
|
|
splatsContext = `\n- 3D scenes:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const trips = getRecentTripsForMI(space, 3);
|
|
if (trips.length > 0) {
|
|
const lines = trips.map((t) => `- "${t.title}" [${t.status}] (${t.destinationCount} destinations)`);
|
|
tripsContext = `\n- Trips:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const bnbListings = getActiveListingsForMI(space, 3);
|
|
if (bnbListings.length > 0) {
|
|
const lines = bnbListings.map((l) => `- "${l.title}" (${l.type}, ${l.economy})`);
|
|
bnbContext = `\n- BnB listings:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const vnbVehicles = getActiveVehiclesForMI(space, 3);
|
|
if (vnbVehicles.length > 0) {
|
|
const lines = vnbVehicles.map((v) => `- "${v.title}" (${v.type}, ${v.economy})`);
|
|
vnbContext = `\n- Vehicles:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const forumInstances = getForumInstancesForMI(space, 3);
|
|
if (forumInstances.length > 0) {
|
|
const lines = forumInstances.map((i) => `- "${i.name}" (${i.domain || "pending"}) [${i.status}]`);
|
|
forumContext = `\n- Forum instances:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const choiceSessions = getRecentChoiceSessionsForMI(space, 3);
|
|
if (choiceSessions.length > 0) {
|
|
const lines = choiceSessions.map((s) => `- "${s.title}" (${s.type}, ${s.optionCount} options)`);
|
|
choicesContext = `\n- Choice sessions:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const csPrompts = getActivePromptsForMI(space, 3);
|
|
if (csPrompts.length > 0) {
|
|
const lines = csPrompts.map((p) => `- "${p.text.slice(0, 60)}" (${p.swipeCount}/${p.threshold})`);
|
|
crowdsurfContext = `\n- Crowdsurf prompts:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const govShapes = getGovShapesForMI(space);
|
|
if (govShapes.length > 0) {
|
|
const lines = govShapes.map((s) => `- ${s.type}: ${s.count}`);
|
|
govContext = `\n- Governance shapes:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const crdtTokens = getCrdtTokensForMI(space, 3);
|
|
if (crdtTokens.length > 0) {
|
|
const lines = crdtTokens.map((t) => `- ${t.symbol} (${t.name}): supply ${t.totalSupply}`);
|
|
walletContext = `\n- CRDT tokens:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
const canvasSummary = getCanvasSummaryForMI(space);
|
|
if (canvasSummary.length > 0 && canvasSummary[0].totalShapes > 0) {
|
|
const s = canvasSummary[0];
|
|
const top = s.typeBreakdown.slice(0, 5).map((t) => `${t.type}: ${t.count}`).join(", ");
|
|
canvasContext = `\n- Canvas: ${s.totalShapes} shapes (${top})`;
|
|
}
|
|
}
|
|
|
|
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}${campaignsContext}${contactsContext}${inboxContext}${commitmentsContext}${filesContext}${remindersContext}${mapsContext}${meetsContext}${tubeContext}${chatsContext}${pubsContext}${swagContext}${sheetsContext}${docsContext}${designContext}${photosContext}${flowsContext}${exchangeContext}${cartContext}${voteContext}${booksContext}${splatsContext}${tripsContext}${bnbContext}${vnbContext}${forumContext}${choicesContext}${crowdsurfContext}${govContext}${walletContext}${canvasContext}
|
|
|
|
## 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 (grouped by category):
|
|
|
|
Core: folk-markdown, folk-wrapper, folk-embed, folk-image, folk-bookmark, folk-slide, folk-chat, folk-piano, folk-canvas, folk-rapp, folk-feed, folk-obs-note, folk-workflow-block, folk-google-item.
|
|
AI: folk-prompt, folk-image-gen, folk-image-studio, folk-video-gen, folk-zine-gen, folk-transcription.
|
|
Creative: folk-splat, folk-drawfast, folk-blender, folk-freecad, folk-kicad, folk-design-agent.
|
|
Social: folk-social-post, folk-social-thread, folk-social-campaign, folk-social-newsletter.
|
|
Decisions: folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, folk-spider-3d.
|
|
Travel: folk-itinerary, folk-destination, folk-booking, folk-budget, folk-packing-list.
|
|
Tokens: folk-token-mint, folk-token-ledger, folk-transaction-builder.
|
|
Geo: folk-holon, folk-holon-browser, folk-map, folk-calendar.
|
|
Video: folk-video-chat.
|
|
|
|
## 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}]
|
|
[MI_ACTION:{"type":"query-content","module":"rsocials","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rnetwork","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rinbox","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rtime","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rfiles","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rschedule","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rmaps","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rmeets","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rtube","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rchats","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rpubs","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rswag","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rsheets","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rdocs","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rdesign","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rphotos","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rflows","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rexchange","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rcart","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rvote","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rbooks","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rsplat","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rtrips","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rbnb","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rvnb","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rforum","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rchoices","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"crowdsurf","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rgov","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rwallet","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rspace","queryType":"recent","limit":5}]
|
|
[MI_ACTION:{"type":"query-content","module":"rdata","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)
|
|
- Tweet threads / multi-post threads → folk-social-thread (set title, tweets props)
|
|
- Marketing campaigns / content plans → folk-social-campaign (set title, description, platforms props)
|
|
- Newsletters / email campaigns → folk-social-newsletter (set subject, listName props)
|
|
- Decisions / polls / questions for voting → folk-choice-vote (set question prop)
|
|
- Ranked choices / priority lists → folk-choice-rank (set question prop)
|
|
- Multi-criteria evaluation → folk-choice-spider (set question prop)
|
|
- Travel destinations → folk-destination (set destName, country props)
|
|
- Trip itineraries → folk-itinerary (set tripTitle, itemsJson props)
|
|
- Bookings / reservations → folk-booking (set bookingType, provider props)
|
|
- Budget / expenses → folk-budget (set budgetTotal props)
|
|
- 3D models / scenes → folk-splat or folk-blender (set src prop)
|
|
- Circuit / PCB design → folk-kicad (set brief prop)
|
|
- CAD / 3D parts → folk-freecad (set brief prop)
|
|
- Print / layout design → folk-design-agent (set brief prop)
|
|
- AI chat / assistant → folk-prompt (start a conversation)
|
|
- Image generation requests → folk-image-gen (set prompt prop)
|
|
- Video generation requests → folk-video-gen (set prompt prop)
|
|
- Zine / publication content → folk-zine-gen (set prompt prop)
|
|
- Audio / transcription → folk-transcription
|
|
- Data feeds from modules → folk-feed (set sourceModule, feedId props)
|
|
- Embed another rApp → folk-rapp (set moduleId prop)
|
|
- Token minting → folk-token-mint (set tokenName, symbol props)
|
|
- Token ledger / balances → folk-token-ledger (set tokenId 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([
|
|
// Core
|
|
"folk-markdown", "folk-wrapper", "folk-embed", "folk-image", "folk-bookmark",
|
|
"folk-slide", "folk-chat", "folk-piano", "folk-canvas", "folk-rapp", "folk-feed",
|
|
"folk-obs-note", "folk-workflow-block", "folk-google-item",
|
|
// AI
|
|
"folk-prompt", "folk-image-gen", "folk-image-studio", "folk-video-gen",
|
|
"folk-zine-gen", "folk-transcription",
|
|
// Creative
|
|
"folk-splat", "folk-drawfast", "folk-blender", "folk-freecad", "folk-kicad",
|
|
"folk-design-agent",
|
|
// Social
|
|
"folk-social-post", "folk-social-thread", "folk-social-campaign", "folk-social-newsletter",
|
|
// Decisions
|
|
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
|
|
"folk-choice-conviction", "folk-spider-3d",
|
|
// Travel
|
|
"folk-itinerary", "folk-destination", "folk-booking", "folk-budget", "folk-packing-list",
|
|
// Tokens
|
|
"folk-token-mint", "folk-token-ledger", "folk-transaction-builder",
|
|
// Geo & Video
|
|
"folk-map", "folk-calendar", "folk-video-chat",
|
|
"folk-holon", "folk-holon-browser",
|
|
]);
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ── POST /suggestions — dynamic data-driven suggestions ──
|
|
|
|
mi.post("/suggestions", async (c) => {
|
|
const { space, module: currentModule } = await c.req.json();
|
|
const suggestions: { label: string; icon: string; prompt: string; autoSend?: boolean }[] = [];
|
|
|
|
if (!space) return c.json({ suggestions });
|
|
|
|
try {
|
|
// Check upcoming events
|
|
const upcoming = getUpcomingEventsForMI(space, 1, 3);
|
|
if (upcoming.length > 0) {
|
|
const next = upcoming[0];
|
|
const startMs = Date.parse(next.start);
|
|
const hoursUntil = Math.round((startMs - Date.now()) / 3600000);
|
|
if (hoursUntil > 0 && hoursUntil <= 24) {
|
|
const timeLabel = hoursUntil === 1 ? "1 hour" : `${hoursUntil} hours`;
|
|
suggestions.push({
|
|
label: `${next.title} in ${timeLabel}`,
|
|
icon: "⏰",
|
|
prompt: `Tell me about the upcoming event "${next.title}"`,
|
|
autoSend: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check open tasks
|
|
const tasks = getRecentTasksForMI(space, 10);
|
|
const openTasks = tasks.filter((t) => t.status !== "DONE");
|
|
if (openTasks.length > 0) {
|
|
suggestions.push({
|
|
label: `${openTasks.length} open task${openTasks.length > 1 ? "s" : ""}`,
|
|
icon: "📋",
|
|
prompt: "Show my open tasks",
|
|
autoSend: true,
|
|
});
|
|
}
|
|
|
|
// Check if current module has zero content — "get started" suggestion
|
|
if (currentModule === "rnotes") {
|
|
const notes = getRecentNotesForMI(space, 1);
|
|
if (notes.length === 0) {
|
|
suggestions.push({
|
|
label: "Create your first note",
|
|
icon: "📝",
|
|
prompt: "Help me create my first notebook",
|
|
autoSend: true,
|
|
});
|
|
}
|
|
} else if (currentModule === "rtasks") {
|
|
const t = getRecentTasksForMI(space, 1);
|
|
if (t.length === 0) {
|
|
suggestions.push({
|
|
label: "Create your first task",
|
|
icon: "✅",
|
|
prompt: "Help me create my first task board",
|
|
autoSend: true,
|
|
});
|
|
}
|
|
} else if (currentModule === "rcal") {
|
|
const ev = getUpcomingEventsForMI(space, 30, 1);
|
|
if (ev.length === 0) {
|
|
suggestions.push({
|
|
label: "Add your first event",
|
|
icon: "📅",
|
|
prompt: "Help me create my first calendar event",
|
|
autoSend: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Recent note to continue editing
|
|
if (currentModule === "rnotes") {
|
|
const recent = getRecentNotesForMI(space, 1);
|
|
if (recent.length > 0) {
|
|
suggestions.push({
|
|
label: `Continue "${recent[0].title}"`,
|
|
icon: "📝",
|
|
prompt: `Help me continue working on "${recent[0].title}"`,
|
|
autoSend: true,
|
|
});
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.error("[mi/suggestions]", e.message);
|
|
}
|
|
|
|
// Max 3 dynamic suggestions
|
|
return c.json({ suggestions: suggestions.slice(0, 3) });
|
|
});
|
|
|
|
// ── 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 /${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 };
|