feat: consistent headers across all rApps + add mi AI assistant
Header consistency: - Fix 52px → 56px header height in 7 module CSS files (pubs, funds, providers, books, swag, choices, cart) - Remove custom header background overrides in books.css and pubs.css - All pages now use the same 3-section header layout: left (app/space switchers), center (mi), right (identity) - Add <rstack-mi> to all 4 standalone HTML pages (index, admin, create-space, canvas) and both shell renderers mi AI assistant: - New <rstack-mi> web component with search input "Ask mi anything..." - Dropdown panel with streaming chat UI, typing indicator, markdown formatting - POST /api/mi/ask endpoint: streams from Ollama with full rApp context in system prompt (all 22 modules, current space/module) - Graceful fallback to keyword-based responses when Ollama unavailable - Configurable via MI_MODEL and OLLAMA_URL env vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
914d0e61c4
commit
0813eed5e0
|
|
@ -5,13 +5,8 @@ body[data-theme="dark"] {
|
|||
background: #0f172a;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .rstack-header {
|
||||
background: #0f172a;
|
||||
border-bottom-color: #1e293b;
|
||||
}
|
||||
|
||||
/* Library grid page */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* Cart module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* Choices module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* ── Funds module theme ───────────────────────────────── */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* Providers module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
/* Pubs module — editor theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .rstack-header {
|
||||
background: #0f172a;
|
||||
border-bottom-color: #1e293b;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* Swag module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,98 @@ app.get("/.well-known/webauthn", (c) => {
|
|||
// ── Space registry API ──
|
||||
app.route("/api/spaces", spaces);
|
||||
|
||||
// ── mi — AI assistant endpoint ──
|
||||
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
|
||||
app.post("/api/mi/ask", async (c) => {
|
||||
const { query, messages = [], space, module: currentModule } = 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");
|
||||
|
||||
const systemPrompt = `You are mi, 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).
|
||||
|
||||
## Available rApps
|
||||
${moduleList}
|
||||
|
||||
## Current Context
|
||||
- Space: ${space || "none selected"}
|
||||
- Active rApp: ${currentModule || "none"}
|
||||
|
||||
## 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 you don't know something specific about the user's data, say so honestly.
|
||||
- Use a warm, knowledgeable tone. You're a guide, not a search engine.`;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
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** (canvas), **rNotes** (notes), **rChat** (messaging), **rFunds** (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) ──
|
||||
|
||||
/** Resolve a community slug to SpaceAuthConfig for the SDK guard */
|
||||
|
|
|
|||
|
|
@ -66,10 +66,16 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
|
||||
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
</header>
|
||||
<div class="rstack-tab-row" data-theme="${theme}">
|
||||
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
|
||||
</div>
|
||||
<main id="app">
|
||||
${body}
|
||||
</main>
|
||||
|
|
@ -77,6 +83,89 @@ export function renderShell(opts: ShellOptions): string {
|
|||
import '/shell.js';
|
||||
// Provide module list to app switcher
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
|
||||
// ── Tab bar / Layer system initialization ──
|
||||
const tabBar = document.querySelector('rstack-tab-bar');
|
||||
const spaceSlug = '${escapeAttr(spaceSlug)}';
|
||||
const currentModuleId = '${escapeAttr(moduleId)}';
|
||||
|
||||
if (tabBar) {
|
||||
// Default layer: current module (bootstrap if no layers saved yet)
|
||||
const defaultLayer = {
|
||||
id: 'layer-' + currentModuleId,
|
||||
moduleId: currentModuleId,
|
||||
label: ${JSON.stringify(modules.find((m: any) => m.id === moduleId)?.name || moduleId)},
|
||||
order: 0,
|
||||
color: '',
|
||||
visible: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
// Set the current module as the active layer
|
||||
tabBar.setLayers([defaultLayer]);
|
||||
tabBar.setAttribute('active', defaultLayer.id);
|
||||
|
||||
// Listen for tab events
|
||||
tabBar.addEventListener('layer-switch', (e) => {
|
||||
const { moduleId } = e.detail;
|
||||
window.location.href = '/' + spaceSlug + '/' + moduleId;
|
||||
});
|
||||
|
||||
tabBar.addEventListener('layer-add', (e) => {
|
||||
const { moduleId } = e.detail;
|
||||
// Navigate to the new module (layer will be persisted when sync connects)
|
||||
window.location.href = '/' + spaceSlug + '/' + moduleId;
|
||||
});
|
||||
|
||||
tabBar.addEventListener('layer-close', (e) => {
|
||||
const { layerId } = e.detail;
|
||||
tabBar.removeLayer(layerId);
|
||||
// If we closed the active layer, switch to first remaining
|
||||
const remaining = tabBar.querySelectorAll?.('[data-layer-id]');
|
||||
// The tab bar handles this internally
|
||||
});
|
||||
|
||||
tabBar.addEventListener('view-toggle', (e) => {
|
||||
const { mode } = e.detail;
|
||||
// When switching to stack view, emit event for canvas to connect
|
||||
document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } }));
|
||||
});
|
||||
|
||||
// Expose tabBar for CommunitySync integration
|
||||
window.__rspaceTabBar = tabBar;
|
||||
|
||||
// If CommunitySync is available, wire up layer persistence
|
||||
document.addEventListener('community-sync-ready', (e) => {
|
||||
const sync = e.detail?.sync;
|
||||
if (!sync) return;
|
||||
|
||||
// Load persisted layers
|
||||
const layers = sync.getLayers();
|
||||
if (layers.length > 0) {
|
||||
tabBar.setLayers(layers);
|
||||
const activeId = sync.doc.activeLayerId;
|
||||
if (activeId) tabBar.setAttribute('active', activeId);
|
||||
tabBar.setFlows(sync.getFlows());
|
||||
} else {
|
||||
// First visit: save the default layer
|
||||
sync.addLayer(defaultLayer);
|
||||
sync.setActiveLayer(defaultLayer.id);
|
||||
}
|
||||
|
||||
// Sync layer changes back to Automerge
|
||||
tabBar.addEventListener('layer-switch', (e) => {
|
||||
sync.setActiveLayer(e.detail.layerId);
|
||||
});
|
||||
|
||||
// Listen for remote layer changes
|
||||
sync.addEventListener('change', () => {
|
||||
tabBar.setLayers(sync.getLayers());
|
||||
tabBar.setFlows(sync.getFlows());
|
||||
const activeId = sync.doc.activeLayerId;
|
||||
if (activeId) tabBar.setAttribute('active', activeId);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
${scripts}
|
||||
</body>
|
||||
|
|
@ -111,6 +200,9 @@ export function renderStandaloneShell(opts: {
|
|||
<span class="rstack-header__brand-gradient">rSpace</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* <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; }
|
||||
}
|
||||
`;
|
||||
|
|
@ -366,6 +366,9 @@
|
|||
<rstack-app-switcher current=""></rstack-app-switcher>
|
||||
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
#toolbar {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
top: 108px; /* header(56) + tab-row(36) + gap(16) */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
|
|
@ -570,6 +570,9 @@
|
|||
<rstack-app-switcher current="canvas"></rstack-app-switcher>
|
||||
<rstack-space-switcher current="" name=""></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -170,6 +170,9 @@
|
|||
<rstack-app-switcher current=""></rstack-app-switcher>
|
||||
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -365,6 +365,9 @@
|
|||
<rstack-app-switcher current=""></rstack-app-switcher>
|
||||
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,14 @@ body {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.rstack-header__center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rstack-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -63,16 +71,37 @@ body {
|
|||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Tab row (below header) ── */
|
||||
|
||||
.rstack-tab-row {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9998;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(128,128,128,0.1);
|
||||
}
|
||||
|
||||
.rstack-tab-row[data-theme="dark"] {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.rstack-tab-row[data-theme="light"] {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* ── Main content area ── */
|
||||
|
||||
#app {
|
||||
padding-top: 56px; /* Below fixed header */
|
||||
padding-top: 92px; /* Below fixed header (56px) + tab row (36px) */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* When canvas module is active, make it fill the viewport */
|
||||
#app.canvas-layout {
|
||||
padding-top: 56px;
|
||||
padding-top: 92px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@
|
|||
import { RStackIdentity } from "../shared/components/rstack-identity";
|
||||
import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
|
||||
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
|
||||
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
||||
import { RStackMi } from "../shared/components/rstack-mi";
|
||||
|
||||
// Register all header components
|
||||
RStackIdentity.define();
|
||||
RStackAppSwitcher.define();
|
||||
RStackSpaceSwitcher.define();
|
||||
RStackTabBar.define();
|
||||
RStackMi.define();
|
||||
|
||||
// Reload space list when user signs in/out (to show/hide private spaces)
|
||||
document.addEventListener("auth-change", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue