917 lines
30 KiB
TypeScript
917 lines
30 KiB
TypeScript
/**
|
|
* <rstack-mi> — AI-powered community builder embedded in the rSpace header.
|
|
*
|
|
* 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, 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";
|
|
|
|
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() {
|
|
this.#shadow.innerHTML = `
|
|
<style>${STYLES}</style>
|
|
<div class="mi">
|
|
<div class="mi-bar" id="mi-bar">
|
|
<span class="mi-icon">✧</span>
|
|
<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 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 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")!;
|
|
|
|
// 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" && !e.shiftKey && input.value.trim()) {
|
|
e.preventDefault();
|
|
this.#ask(input.value.trim());
|
|
input.value = "";
|
|
this.#autoResize(input);
|
|
}
|
|
if (e.key === "Escape") {
|
|
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) && !pill.contains(e.target as Node)) {
|
|
panel.classList.remove("open");
|
|
bar.classList.remove("focused");
|
|
}
|
|
});
|
|
|
|
// 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) {
|
|
let baseText = "";
|
|
this.#dictation = new SpeechDictation({
|
|
onInterim: (text) => {
|
|
this.#interimText = text;
|
|
barInput.value = baseText + (baseText ? " " : "") + text;
|
|
},
|
|
onFinal: (text) => {
|
|
this.#interimText = "";
|
|
baseText += (baseText ? " " : "") + text;
|
|
barInput.value = baseText;
|
|
},
|
|
onStateChange: (recording) => {
|
|
micBtn.classList.toggle("recording", recording);
|
|
if (!recording) {
|
|
baseText = barInput.value;
|
|
this.#interimText = "";
|
|
}
|
|
},
|
|
onError: (err) => console.warn("MI dictation:", err),
|
|
});
|
|
|
|
micBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.#dictation!.isRecording) {
|
|
baseText = barInput.value;
|
|
}
|
|
this.#dictation!.toggle();
|
|
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> = {};
|
|
|
|
ctx.space = document.querySelector("rstack-space-switcher")?.getAttribute("current") || "";
|
|
ctx.module = document.querySelector("rstack-app-switcher")?.getAttribute("current") || "";
|
|
|
|
// Deep canvas context from MI bridge
|
|
const bridge = (window as any).__miCanvasBridge;
|
|
if (bridge) {
|
|
const cc = bridge.getCanvasContext();
|
|
ctx.openShapes = cc.allShapes.slice(0, 20).map((s: any) => ({
|
|
type: s.type,
|
|
id: s.id,
|
|
x: Math.round(s.x),
|
|
y: Math.round(s.y),
|
|
width: Math.round(s.width),
|
|
height: Math.round(s.height),
|
|
...(s.content ? { snippet: s.content.slice(0, 80) } : {}),
|
|
...(s.title ? { title: s.title } : {}),
|
|
}));
|
|
if (cc.selectedShapes.length) {
|
|
ctx.selectedShapes = cc.selectedShapes.map((s: any) => ({
|
|
type: s.type,
|
|
id: s.id,
|
|
x: Math.round(s.x),
|
|
y: Math.round(s.y),
|
|
...(s.content ? { snippet: s.content.slice(0, 80) } : {}),
|
|
...(s.title ? { title: s.title } : {}),
|
|
}));
|
|
}
|
|
if (cc.connections.length) ctx.connections = cc.connections;
|
|
ctx.viewport = cc.viewport;
|
|
if (cc.shapeGroups.length) ctx.shapeGroups = cc.shapeGroups;
|
|
ctx.shapeCountByType = cc.shapeCountByType;
|
|
} else {
|
|
const canvasContent = document.getElementById("canvas-content");
|
|
if (canvasContent) {
|
|
const shapes = [...canvasContent.children]
|
|
.filter((el) => el.tagName?.includes("-") && el.id)
|
|
.map((el: any) => ({
|
|
type: el.tagName.toLowerCase(),
|
|
id: el.id,
|
|
...(el.content ? { snippet: el.content.slice(0, 60) } : {}),
|
|
...(el.title ? { title: el.title } : {}),
|
|
}))
|
|
.slice(0, 20);
|
|
if (shapes.length) ctx.openShapes = shapes;
|
|
}
|
|
}
|
|
|
|
const tabBar = document.querySelector("rstack-tab-bar");
|
|
if (tabBar) {
|
|
ctx.activeTab = tabBar.getAttribute("active") || "";
|
|
}
|
|
ctx.pageTitle = document.title;
|
|
|
|
return ctx;
|
|
}
|
|
|
|
async #ask(query: string) {
|
|
const panel = this.#shadow.getElementById("mi-panel")!;
|
|
const messagesEl = this.#shadow.getElementById("mi-messages")!;
|
|
|
|
panel.classList.add("open");
|
|
this.#shadow.getElementById("mi-bar")!.classList.add("focused");
|
|
|
|
// Add user message
|
|
this.#messages.push({ role: "user", content: query });
|
|
this.#renderMessages(messagesEl);
|
|
|
|
// Add placeholder for assistant
|
|
this.#messages.push({ role: "assistant", content: "" });
|
|
const assistantIdx = this.#messages.length - 1;
|
|
this.#renderMessages(messagesEl);
|
|
|
|
// Abort previous
|
|
this.#abortController?.abort();
|
|
this.#abortController = new AbortController();
|
|
|
|
try {
|
|
const token = getAccessToken();
|
|
const context = this.#gatherContext();
|
|
const res = await fetch("/api/mi/ask", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
query,
|
|
messages: this.#messages.slice(0, -1).slice(-10),
|
|
space: context.space,
|
|
module: context.module,
|
|
context,
|
|
model: this.#preferredModel || undefined,
|
|
}),
|
|
signal: this.#abortController.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
|
throw new Error(err.error || "Request failed");
|
|
}
|
|
|
|
if (!res.body) throw new Error("No response stream");
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
for (const line of chunk.split("\n").filter(Boolean)) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
if (data.message?.content) {
|
|
this.#messages[assistantIdx].content += data.message.content;
|
|
}
|
|
if (data.response) {
|
|
this.#messages[assistantIdx].content = data.response;
|
|
}
|
|
} catch { /* skip malformed lines */ }
|
|
}
|
|
this.#renderMessages(messagesEl);
|
|
}
|
|
|
|
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
|
|
const rawText = this.#messages[assistantIdx].content;
|
|
const { displayText, actions } = parseMiActions(rawText);
|
|
this.#messages[assistantIdx].content = displayText;
|
|
|
|
if (actions.length) {
|
|
await this.#executeWithConfirmation(actions, context);
|
|
this.#messages[assistantIdx].actionSummary = summariseActions(actions);
|
|
this.#messages[assistantIdx].actionDetails = detailedActionSummary(actions);
|
|
}
|
|
|
|
// Check for tool suggestions
|
|
const hints = suggestTools(query);
|
|
if (hints.length) {
|
|
this.#messages[assistantIdx].toolHints = hints;
|
|
}
|
|
|
|
this.#renderMessages(messagesEl);
|
|
}
|
|
} catch (e: any) {
|
|
if (e.name !== "AbortError") {
|
|
this.#messages[this.#messages.length - 1].content =
|
|
"Sorry, I'm not available right now. Please try again later.";
|
|
this.#renderMessages(messagesEl);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(
|
|
(m) => `
|
|
<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 ? `<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>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
// Wire tool chip clicks
|
|
container.querySelectorAll<HTMLButtonElement>(".mi-tool-chip").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const tag = btn.dataset.tag;
|
|
if (!tag) return;
|
|
const executor = new MiActionExecutor();
|
|
executor.execute([{ type: "create-shape", tagName: tag, props: {} }]);
|
|
});
|
|
});
|
|
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
#escapeHtml(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
#formatContent(s: string): string {
|
|
let escaped = s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.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") {
|
|
if (!customElements.get(tag)) customElements.define(tag, RStackMi);
|
|
}
|
|
}
|
|
|
|
const STYLES = `
|
|
:host { display: contents; }
|
|
|
|
.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;
|
|
transition: all 0.2s;
|
|
background: var(--rs-btn-secondary-bg);
|
|
}
|
|
.mi-bar.focused { background: var(--rs-bg-hover); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
|
|
|
|
.mi-icon {
|
|
font-size: 0.9rem; flex-shrink: 0;
|
|
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.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-bar::placeholder { color: var(--rs-text-muted); }
|
|
|
|
.mi-mic-btn {
|
|
background: none; border: none; cursor: pointer; padding: 2px 4px;
|
|
font-size: 0.85rem; border-radius: 6px; transition: all 0.2s;
|
|
flex-shrink: 0; line-height: 1;
|
|
}
|
|
.mi-mic-btn:hover { background: var(--rs-bg-hover); }
|
|
.mi-mic-btn.recording {
|
|
animation: micPulse 1.5s infinite;
|
|
filter: saturate(2) brightness(1.1);
|
|
}
|
|
@keyframes micPulse {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.15); }
|
|
}
|
|
|
|
/* ── Rich panel ── */
|
|
.mi-panel {
|
|
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;
|
|
}
|
|
|
|
.mi-welcome { text-align: center; padding: 24px 16px; }
|
|
.mi-welcome-icon {
|
|
font-size: 2rem; display: block; margin-bottom: 8px;
|
|
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
}
|
|
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; color: var(--rs-text-primary); }
|
|
.mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; }
|
|
|
|
.mi-msg { display: flex; flex-direction: column; gap: 4px; }
|
|
.mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.mi-msg--user .mi-msg-who { color: #06b6d4; }
|
|
.mi-msg--assistant .mi-msg-who {
|
|
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.mi-msg-body {
|
|
font-size: 0.85rem; line-height: 1.6; word-break: break-word;
|
|
color: var(--rs-text-secondary);
|
|
}
|
|
.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%;
|
|
animation: miBounce 1.2s ease-in-out infinite;
|
|
background: var(--rs-text-muted);
|
|
}
|
|
.mi-typing span:nth-child(2) { animation-delay: 0.15s; }
|
|
.mi-typing span:nth-child(3) { animation-delay: 0.3s; }
|
|
@keyframes miBounce {
|
|
0%, 60%, 100% { transform: translateY(0); }
|
|
30% { transform: translateY(-4px); }
|
|
}
|
|
|
|
/* ── Action chips (collapsible) ── */
|
|
.mi-action-details { margin-top: 6px; }
|
|
.mi-action-chip {
|
|
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; }
|
|
.mi-tool-chip {
|
|
padding: 4px 10px; border-radius: 8px; border: none;
|
|
font-size: 0.75rem; cursor: pointer; transition: background 0.15s;
|
|
font-family: inherit;
|
|
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
|
|
}
|
|
.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 {
|
|
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;
|
|
}
|
|
}
|
|
`;
|