297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
/**
|
|
* <rstack-mi> — AI-powered assistant 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.
|
|
*/
|
|
|
|
import { getAccessToken } from "./rstack-identity";
|
|
|
|
interface MiMessage {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
export class RStackMi extends HTMLElement {
|
|
#shadow: ShadowRoot;
|
|
#messages: MiMessage[] = [];
|
|
#abortController: AbortController | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#render();
|
|
}
|
|
|
|
#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" id="mi-input" type="text"
|
|
placeholder="Ask mi anything..." autocomplete="off" />
|
|
</div>
|
|
<div class="mi-panel" id="mi-panel">
|
|
<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 guide to rSpace.</p>
|
|
<p class="mi-welcome-sub">Ask me about any rApp, how to find things, or what you can do here.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const input = this.#shadow.getElementById("mi-input") as HTMLInputElement;
|
|
const panel = this.#shadow.getElementById("mi-panel")!;
|
|
const bar = this.#shadow.getElementById("mi-bar")!;
|
|
|
|
input.addEventListener("focus", () => {
|
|
panel.classList.add("open");
|
|
bar.classList.add("focused");
|
|
});
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && input.value.trim()) {
|
|
this.#ask(input.value.trim());
|
|
input.value = "";
|
|
}
|
|
if (e.key === "Escape") {
|
|
panel.classList.remove("open");
|
|
bar.classList.remove("focused");
|
|
input.blur();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (!this.contains(e.target as Node)) {
|
|
panel.classList.remove("open");
|
|
bar.classList.remove("focused");
|
|
}
|
|
});
|
|
|
|
// Stop clicks inside the panel from closing it
|
|
panel.addEventListener("click", (e) => e.stopPropagation());
|
|
bar.addEventListener("click", (e) => e.stopPropagation());
|
|
}
|
|
|
|
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 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: document.querySelector("rstack-space-switcher")?.getAttribute("current") || "",
|
|
module: document.querySelector("rstack-app-switcher")?.getAttribute("current") || "",
|
|
}),
|
|
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;
|
|
}
|
|
// Non-streaming fallback
|
|
if (data.response) {
|
|
this.#messages[assistantIdx].content = data.response;
|
|
}
|
|
} catch { /* skip malformed lines */ }
|
|
}
|
|
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);
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#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>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
#formatContent(s: string): string {
|
|
// Escape HTML then convert markdown-like formatting
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/`(.+?)`/g, '<code class="mi-code">$1</code>')
|
|
.replace(/\n/g, "<br>");
|
|
}
|
|
|
|
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; }
|
|
|
|
.mi-bar {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 6px 14px; border-radius: 10px;
|
|
transition: all 0.2s;
|
|
}
|
|
:host-context([data-theme="dark"]) .mi-bar { background: rgba(255,255,255,0.06); }
|
|
:host-context([data-theme="light"]) .mi-bar { background: rgba(0,0,0,0.04); }
|
|
:host-context([data-theme="dark"]) .mi-bar.focused { background: rgba(255,255,255,0.1); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
|
|
:host-context([data-theme="light"]) .mi-bar.focused { background: rgba(0,0,0,0.06); 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 {
|
|
flex: 1; border: none; outline: none; background: none;
|
|
font-size: 0.85rem; min-width: 0;
|
|
font-family: inherit;
|
|
}
|
|
:host-context([data-theme="dark"]) .mi-input { color: #e2e8f0; }
|
|
:host-context([data-theme="light"]) .mi-input { color: #0f172a; }
|
|
:host-context([data-theme="dark"]) .mi-input::placeholder { color: #64748b; }
|
|
:host-context([data-theme="light"]) .mi-input::placeholder { color: #94a3b8; }
|
|
|
|
.mi-panel {
|
|
position: absolute; top: calc(100% + 8px); left: 0; right: 0;
|
|
min-width: 360px; max-height: 420px;
|
|
border-radius: 14px; overflow: hidden;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
|
|
display: none; z-index: 300;
|
|
}
|
|
.mi-panel.open { display: flex; flex-direction: column; }
|
|
:host-context([data-theme="dark"]) .mi-panel { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
|
|
:host-context([data-theme="light"]) .mi-panel { background: white; border: 1px solid rgba(0,0,0,0.1); }
|
|
|
|
.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; }
|
|
.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;
|
|
}
|
|
:host-context([data-theme="dark"]) .mi-welcome p { color: #e2e8f0; }
|
|
:host-context([data-theme="light"]) .mi-welcome p { color: #374151; }
|
|
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; }
|
|
.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; }
|
|
:host-context([data-theme="dark"]) .mi-msg--user .mi-msg-who { color: #06b6d4; }
|
|
:host-context([data-theme="light"]) .mi-msg--user .mi-msg-who { color: #0891b2; }
|
|
.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;
|
|
}
|
|
:host-context([data-theme="dark"]) .mi-msg-body { color: #cbd5e1; }
|
|
:host-context([data-theme="light"]) .mi-msg-body { color: #374151; }
|
|
:host-context([data-theme="dark"]) .mi-msg--user .mi-msg-body { color: #e2e8f0; }
|
|
:host-context([data-theme="light"]) .mi-msg--user .mi-msg-body { color: #0f172a; }
|
|
|
|
.mi-code {
|
|
padding: 1px 5px; border-radius: 4px; font-size: 0.8rem;
|
|
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
|
}
|
|
:host-context([data-theme="dark"]) .mi-code { background: rgba(255,255,255,0.08); color: #7dd3fc; }
|
|
:host-context([data-theme="light"]) .mi-code { background: rgba(0,0,0,0.06); color: #0284c7; }
|
|
|
|
.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;
|
|
}
|
|
:host-context([data-theme="dark"]) .mi-typing span { background: #64748b; }
|
|
:host-context([data-theme="light"]) .mi-typing span { background: #94a3b8; }
|
|
.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); }
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.mi { max-width: 200px; }
|
|
.mi-panel { min-width: 300px; left: -60px; }
|
|
}
|
|
`;
|