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:
parent
a93d124d4b
commit
eb00579183
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
|
|
@ -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" } },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
407
server/index.ts
407
server/index.ts
|
|
@ -86,6 +86,7 @@ import { notificationRouter } from "./notification-routes";
|
|||
import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service";
|
||||
import { SystemClock } from "./clock-service";
|
||||
import type { ClockPayload } from "./clock-service";
|
||||
import { miRoutes } from "./mi-routes";
|
||||
|
||||
// Register modules
|
||||
registerModule(canvasModule);
|
||||
|
|
@ -233,6 +234,142 @@ app.get("/api/link-preview", async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Ecosystem manifest (self-declaration) ──
|
||||
// Serves rSpace's own manifest so other ecosystem apps can discover it
|
||||
app.get("/.well-known/rspace-manifest.json", (c) => {
|
||||
const modules = getModuleInfoList();
|
||||
const shapes = modules.map((m) => ({
|
||||
tagName: `folk-rapp`,
|
||||
name: m.name,
|
||||
description: m.description || "",
|
||||
defaults: { width: 500, height: 400 },
|
||||
portDescriptors: [],
|
||||
eventDescriptors: [],
|
||||
config: { moduleId: m.id },
|
||||
}));
|
||||
|
||||
return c.json(
|
||||
{
|
||||
appId: "rspace",
|
||||
name: "rSpace",
|
||||
version: "1.0.0",
|
||||
icon: "🌐",
|
||||
description: "Local-first workspace for the r-ecosystem",
|
||||
homepage: "https://rspace.online",
|
||||
moduleUrl: "/assets/folk-rapp.js",
|
||||
color: "#6366f1",
|
||||
embeddingModes: ["trusted", "sandboxed"],
|
||||
shapes,
|
||||
minProtocolVersion: 1,
|
||||
},
|
||||
200,
|
||||
{
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ── Ecosystem manifest proxy (CORS avoidance) ──
|
||||
// Fetches external app manifests server-side so canvas clients avoid CORS issues
|
||||
const ecosystemManifestCache = new Map<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 ──
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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">✧</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">✧</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)">−</button>
|
||||
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="mi-messages" id="mi-messages">
|
||||
<div class="mi-welcome">
|
||||
<span class="mi-welcome-icon">✧</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">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mi-pill" id="mi-pill">
|
||||
<span class="mi-pill-icon">✧</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" : "✧ 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`(.+?)`/g, '<code class="mi-code">$1</code>')
|
||||
.replace(/\n/g, "<br>");
|
||||
.replace(/>/g, ">");
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue