Merge pull request #37 from multica-ai/feat/web-v1

Feat/web v1
This commit is contained in:
Naiyuan Qing 2026-01-30 23:40:08 +08:00 committed by GitHub
commit fd46d7ba9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3135 additions and 115 deletions

140
CLAUDE.md
View file

@ -1,43 +1,107 @@
# Project Instructions for AI Agents
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Super Multica is a distributed AI agent framework with a monorepo architecture. It includes an agent engine with multi-provider LLM support, a WebSocket gateway, a console hub for multi-agent coordination, and frontend apps (Next.js web, Electron desktop).
## Monorepo Structure
- **`src/`** — Core modules (agent engine, gateway, console, client, shared types)
- **`apps/web`** — Next.js 16 web app (`@multica/web`, port 3001)
- **`apps/desktop`** — Electron + Vite + React desktop app (`@multica/desktop`)
- **`packages/ui`** — Shared UI component library (`@multica/ui`, Shadcn/Tailwind CSS v4)
- **`packages/sdk`** — Gateway client SDK (`@multica/sdk`, Socket.io)
- **`packages/store`** — Zustand state management (`@multica/store`)
- **`skills/`** — Bundled agent skills (commit, code-review, skill-creator)
## Common Commands
```bash
# Install dependencies
pnpm install
# Development (all services concurrently: gateway:3000, console, web:3001)
pnpm dev
# Individual services
pnpm dev:gateway # WebSocket gateway only
pnpm dev:console # NestJS console with agent
pnpm dev:web # Next.js web app
pnpm dev:desktop # Electron desktop app
# Agent CLI
pnpm agent:cli # Non-interactive agent
pnpm agent:interactive # Interactive REPL mode
# Build (turbo-orchestrated)
pnpm build
# Type checking
pnpm typecheck
# Testing (vitest, tests live in src/**/*.test.ts)
pnpm test # Single run
pnpm test:watch # Watch mode
pnpm test:coverage # With v8 coverage
```
## Architecture
```
Frontend (web:3001 / desktop)
@multica/sdk (GatewayClient, Socket.io)
→ Gateway (NestJS, WebSocket, port 3000)
→ Console Hub (multi-agent coordination)
→ Agent Engine (LLM runner, sessions, skills, tools)
```
**Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary).
**Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for real-time message passing, RPC request/response, and streaming.
**Console** (`src/console/`): NestJS hub for multi-agent coordination with a web dashboard.
## Tech Stack & Config
- **Package manager**: pnpm 10 with workspaces (`pnpm-workspace.yaml`)
- **Build orchestration**: Turborepo (`turbo.json`)
- **TypeScript**: ESNext target, NodeNext modules, strict mode, `verbatimModuleSyntax`, `experimentalDecorators` (NestJS)
- **Testing**: Vitest with globals enabled, node environment
- **Frontend**: React 19, Next.js 16, Tailwind CSS v4, Shadcn/UI (zinc base, hugeicons)
- **Backend**: NestJS 11, Socket.io, Pino logging
- **CLI bundling**: esbuild → `bin/` directory
## Environment Setup
```bash
cp .env.example .env
# Set LLM_PROVIDER and corresponding API keys
# Supported: openai, anthropic, deepseek, kimi-coding, groq, mistral, together, google
# .env is loaded automatically via --env-file in all dev/agent scripts
```
## Atomic Commits
After completing any task that modifies code, you MUST create atomic commits before ending the conversation. Do not ask for permission - just do it.
After completing any task that modifies code, you MUST create atomic commits before ending the conversation.
### Workflow
1. Run `git status` and `git diff` to see all modifications
2. Skip if no changes exist
3. Group changes by logical purpose (feature, fix, refactor, docs, test, chore)
4. Stage and commit each group separately
1. **Check for changes**: Run `git status` and `git diff` to see all modifications
2. **Skip if clean**: If there are no changes, skip the commit process
3. **Analyze changes**: Group changes by their logical purpose:
- Feature additions
- Bug fixes
- Refactoring
- Documentation
- Tests
- Configuration/dependencies
4. **Create atomic commits**: For each logical group, stage only the relevant files and create a separate commit
**Format**: Conventional commits — `<type>(<scope>): <description>`
### Commit Process
Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`
For each logical group of changes:
```bash
# Stage specific files for this logical change
git add <file1> <file2>
# Commit with conventional commit message
git commit -m "<type>(<scope>): <description>"
```
### Commit Message Format
Use conventional commits:
- `feat`: New feature
- `fix`: Bug fix
- `refactor`: Code refactoring (no functional change)
- `docs`: Documentation changes
- `test`: Adding or updating tests
- `chore`: Build, config, dependencies
**Rules**:
- Each commit should be independently meaningful and buildable
- Related test files go with their implementation
- Never create empty commits or combine unrelated changes
- If all changes are related to one logical unit, a single commit is fine
- Keep commit messages concise but descriptive
- `git commit --amend` only for immediate small fixes to the last commit
### Examples
@ -51,13 +115,3 @@ Create three commits:
1. `git add src/api/user.ts src/api/user.test.ts && git commit -m "feat(api): add user profile endpoint"`
2. `git add src/utils/format.ts && git commit -m "refactor(utils): simplify date formatting logic"`
3. `git add README.md && git commit -m "docs: update API documentation"`
### Rules
- Each commit should be independently meaningful and buildable
- Related test files should be committed with their implementation
- Never create empty commits
- Never combine unrelated changes in one commit
- Keep commit messages concise but descriptive
- If all changes are related to one logical unit, a single commit is fine
- `git commit --amend` can be used for immediate small fixes to the last commit, but not for unrelated changes

View file

@ -0,0 +1,137 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { SidebarTrigger } from "@multica/ui/components/ui/sidebar";
import { Badge } from "@multica/ui/components/ui/badge";
import { Button } from "@multica/ui/components/ui/button";
import { ChatInput } from "@multica/ui/components/chat-input";
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { HugeiconsIcon } from "@hugeicons/react";
import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
import { toast } from "@multica/ui/components/ui/sonner";
import { useMessages } from "../hooks/use-messages";
import { useGateway } from "../hooks/use-gateway";
import { useHubStore } from "../hooks/use-hub-store";
import { useDeviceId } from "../hooks/use-device-id";
import { useScrollFade } from "../hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
const STATE_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
registered: "default",
connected: "secondary",
connecting: "secondary",
disconnected: "destructive",
}
export function Chat() {
const activeAgentId = useHubStore((s) => s.activeAgentId)
const hub = useHubStore((s) => s.hub)
const { messages, addUserMessage, addAssistantMessage } = useMessages()
const { state: gwState, send } = useGateway({
onMessage: (msg) => {
const payload = msg.payload as { agentId?: string; content?: string }
if (payload?.agentId && payload?.content) {
addAssistantMessage(payload.content, payload.agentId)
}
},
})
const handleSend = (text: string) => {
if (!hub?.hubId || !activeAgentId) return
addUserMessage(text, activeAgentId)
send(hub.hubId, "message", { agentId: activeAgentId, content: text })
}
const filtered = activeAgentId
? messages.filter(m => m.agentId === activeAgentId)
: []
const canSend = gwState === "registered" && !!activeAgentId
const deviceId = useDeviceId()
const [deviceCopied, setDeviceCopied] = useState(false)
const handleCopyDevice = useCallback(async () => {
if (!deviceId) return
await navigator.clipboard.writeText(deviceId)
setDeviceCopied(true)
toast.success("Device ID copied")
setTimeout(() => setDeviceCopied(false), 2000)
}, [deviceId])
const mainRef = useRef<HTMLElement>(null)
const fadeStyle = useScrollFade(mainRef)
return (
<div className="h-dvh flex flex-col overflow-hidden w-full">
<header className="flex items-center gap-2 p-2">
<SidebarTrigger />
{deviceId && (
<>
<span className="text-xs text-muted-foreground font-mono">
{deviceId}
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopyDevice}
aria-label="Copy device ID"
>
<HugeiconsIcon
icon={deviceCopied ? CheckmarkCircle02Icon : Copy01Icon}
strokeWidth={2}
className={cn("size-3", deviceCopied && "text-green-500")}
/>
</Button>
</>
)}
<Badge variant={STATE_VARIANT[gwState] ?? "outline"} className="text-xs">
{gwState}
</Badge>
</header>
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{!activeAgentId ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<HugeiconsIcon icon={UserIcon} strokeWidth={1.5} className="size-10 opacity-30" />
<span className="text-sm">Select an agent to start chatting</span>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Send a message to start the conversation
</div>
) : (
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
{filtered.map((msg) => (
<div
key={msg.id}
className={cn(
"flex",
msg.role === "user" ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full max-w-[90%] p-1 px-2.5"
)}
>
<MemoizedMarkdown mode="minimal" id={msg.id}>
{msg.content}
</MemoizedMarkdown>
</div>
</div>
))}
</div>
)}
</main>
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
<ChatInput
onSubmit={handleSend}
disabled={!canSend}
placeholder={!activeAgentId ? "Select an agent first..." : "Type a message..."}
/>
</footer>
</div>
);
}

View file

@ -0,0 +1,110 @@
"use client"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarGroupAction,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar"
import { Button } from "@multica/ui/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { PlusSignIcon, Delete02Icon } from "@hugeicons/core-free-icons"
import { useHubStore } from "../hooks/use-hub-store"
import { useHubInit } from "../hooks/use-hub-init"
const STATUS_DOT: Record<string, string> = {
connected: "bg-green-500/60",
loading: "bg-yellow-500/50 animate-pulse",
error: "bg-red-500/60",
idle: "bg-muted-foreground/50",
}
const STATUS_LABEL: Record<string, string> = {
connected: "Connected",
loading: "Connecting...",
error: "Disconnected",
idle: "Idle",
}
export function HubSidebar() {
useHubInit()
const status = useHubStore((s) => s.status)
const hub = useHubStore((s) => s.hub)
const agents = useHubStore((s) => s.agents)
const activeAgentId = useHubStore((s) => s.activeAgentId)
const fetchHub = useHubStore((s) => s.fetchHub)
const createAgent = useHubStore((s) => s.createAgent)
const deleteAgent = useHubStore((s) => s.deleteAgent)
const setActiveAgentId = useHubStore((s) => s.setActiveAgentId)
return (
<>
<SidebarGroup>
<SidebarGroupLabel>Hub</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex items-center gap-2 px-2 py-1 text-sm">
<span className={`size-2 rounded-full shrink-0 ${STATUS_DOT[status]}`} />
<span className="text-muted-foreground/70 text-xs">{STATUS_LABEL[status]}</span>
</div>
{status === "connected" && hub && (
<div className="px-2 text-xs text-muted-foreground/50 font-mono truncate">
{hub.hubId}
</div>
)}
{status === "error" && (
<div className="px-2 pt-1">
<Button variant="outline" size="sm" onClick={fetchHub} className="w-full text-xs">
Retry
</Button>
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
{status === "connected" && (
<SidebarGroup>
<SidebarGroupLabel>Agents</SidebarGroupLabel>
<SidebarGroupAction onClick={createAgent} title="Create agent">
<HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} className="size-4" />
</SidebarGroupAction>
<SidebarGroupContent>
<SidebarMenu>
{agents.length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground/60">
No agents
</div>
)}
{agents.map(agent => (
<SidebarMenuItem key={agent.id} className="group/agent-item">
<div
role="button"
onClick={() => setActiveAgentId(agent.id)}
data-active={agent.id === activeAgentId || undefined}
className="flex items-center w-full h-8 px-2 rounded-md cursor-pointer hover:bg-sidebar-accent hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-active:font-medium"
>
<span className="flex-1 min-w-0 truncate font-mono text-xs">
{agent.id}
</span>
<button
onClick={(e) => {
e.stopPropagation()
deleteAgent(agent.id)
}}
title="Delete agent"
className="shrink-0 size-5 flex items-center justify-center rounded-md opacity-0 group-hover/agent-item:opacity-100 hover:bg-sidebar-accent-foreground/10 text-muted-foreground transition-opacity cursor-pointer"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.5} className="size-3.5" />
</button>
</div>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,26 @@
import { useSyncExternalStore } from "react"
import { v7 as uuidv7 } from "uuid"
const STORAGE_KEY = "multica-device-id"
function getSnapshot(): string {
let id = localStorage.getItem(STORAGE_KEY)
if (!id) {
id = uuidv7()
localStorage.setItem(STORAGE_KEY, id)
}
return id
}
function subscribe(cb: () => void) {
window.addEventListener("storage", cb)
return () => window.removeEventListener("storage", cb)
}
function getServerSnapshot(): string {
return ""
}
export function useDeviceId(): string {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

View file

@ -0,0 +1,44 @@
import { useEffect, useRef, useState, useCallback } from "react"
import { GatewayClient, type ConnectionState, type RoutedMessage } from "@multica/sdk"
import { useDeviceId } from "./use-device-id"
import { GATEWAY_URL } from "../lib/config"
interface UseGatewayOptions {
onMessage?: (msg: RoutedMessage) => void
}
export function useGateway(options?: UseGatewayOptions) {
const deviceId = useDeviceId()
const [state, setState] = useState<ConnectionState>("disconnected")
const clientRef = useRef<GatewayClient | null>(null)
const onMessageRef = useRef(options?.onMessage)
useEffect(() => {
onMessageRef.current = options?.onMessage
})
useEffect(() => {
if (!deviceId) return
const client = new GatewayClient({
url: GATEWAY_URL,
deviceId,
deviceType: "client",
})
.onStateChange(setState)
.onMessage(msg => onMessageRef.current?.(msg))
clientRef.current = client
client.connect()
return () => { client.disconnect() }
}, [deviceId])
const send = useCallback(
(to: string, action: string, payload: unknown) => {
clientRef.current?.send(to, action, payload)
},
[]
)
return { state, send }
}

View file

@ -0,0 +1,19 @@
"use client"
import { useEffect } from "react"
import { useHubStore } from "./use-hub-store"
export function useHubInit() {
const fetchHub = useHubStore((s) => s.fetchHub)
const status = useHubStore((s) => s.status)
const fetchAgents = useHubStore((s) => s.fetchAgents)
useEffect(() => { fetchHub() }, [fetchHub])
useEffect(() => {
if (status === "connected") fetchAgents()
}, [status, fetchAgents])
useEffect(() => {
const id = setInterval(fetchHub, 30_000)
return () => clearInterval(id)
}, [fetchHub])
}

View file

@ -0,0 +1,75 @@
import { create } from "zustand"
import { CONSOLE_URL } from "../lib/config"
interface HubInfo {
hubId: string
url: string
connectionState: string
agentCount: number
}
interface Agent {
id: string
closed: boolean
}
type HubStatus = "idle" | "loading" | "connected" | "error"
interface HubStore {
status: HubStatus
hub: HubInfo | null
agents: Agent[]
activeAgentId: string | null
setActiveAgentId: (id: string | null) => void
fetchHub: () => Promise<void>
fetchAgents: () => Promise<void>
createAgent: () => Promise<void>
deleteAgent: (id: string) => Promise<void>
}
export const useHubStore = create<HubStore>()((set, get) => ({
status: "idle",
hub: null,
agents: [],
activeAgentId: null,
setActiveAgentId: (id) => set({ activeAgentId: id }),
fetchHub: async () => {
set({ status: "loading" })
try {
const res = await fetch(`${CONSOLE_URL}/api/hub`)
if (!res.ok) throw new Error(res.statusText)
const data: HubInfo = await res.json()
set({
hub: data,
status: data.connectionState === "registered" ? "connected" : "error",
})
} catch {
set({ status: "error", hub: null })
}
},
fetchAgents: async () => {
try {
const res = await fetch(`${CONSOLE_URL}/api/agents`)
if (res.ok) set({ agents: await res.json() })
} catch { /* silent */ }
},
createAgent: async () => {
const res = await fetch(`${CONSOLE_URL}/api/agents`, { method: "POST" })
await get().fetchAgents()
if (res.ok) {
const data = await res.json()
if (data.id) set({ activeAgentId: data.id })
}
},
deleteAgent: async (id) => {
if (get().activeAgentId === id) set({ activeAgentId: null })
await fetch(`${CONSOLE_URL}/api/agents/${id}`, { method: "DELETE" })
await get().fetchAgents()
},
}))

View file

@ -0,0 +1,25 @@
import { useState, useCallback } from "react"
import { v7 as uuidv7 } from "uuid"
export interface Message {
id: string
role: "user" | "assistant"
content: string
agentId: string
}
export function useMessages() {
const [messages, setMessages] = useState<Message[]>([])
const addUserMessage = useCallback((content: string, agentId: string) => {
setMessages(prev => [...prev, { id: uuidv7(), role: "user", content, agentId }])
}, [])
const addAssistantMessage = useCallback((content: string, agentId: string) => {
setMessages(prev => [...prev, { id: uuidv7(), role: "assistant", content, agentId }])
}, [])
const clearMessages = useCallback(() => setMessages([]), [])
return { messages, addUserMessage, addAssistantMessage, clearMessages }
}

View file

@ -0,0 +1,68 @@
import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
/**
* Returns a dynamic maskImage style based on scroll position.
* - At top fade bottom only
* - At bottom fade top only
* - In middle fade both
* - No overflow undefined (no mask)
*/
export function useScrollFade(
ref: RefObject<HTMLElement | null>,
fadeSize = 32
): CSSProperties | undefined {
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const scrollable = scrollHeight - clientHeight;
if (scrollable <= 0) {
setFade("none");
return;
}
const atTop = scrollTop <= 1;
const atBottom = scrollTop >= scrollable - 1;
if (atTop && atBottom) setFade("none");
else if (atTop) setFade("bottom");
else if (atBottom) setFade("top");
else setFade("both");
}, [ref]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const frame = requestAnimationFrame(update);
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
cancelAnimationFrame(frame);
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, [ref, update]);
if (fade === "none") return undefined;
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
const bottom =
fade === "bottom" || fade === "both"
? `black calc(100% - ${fadeSize}px), transparent 100%`
: "black 100%";
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
return {
maskImage: gradient,
WebkitMaskImage: gradient,
};
}

BIN
apps/web/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View file

@ -1,8 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter } from "next/font/google";
import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google";
import "@multica/ui/globals.css";
import {
SidebarProvider,
SidebarInset,
} from "@multica/ui/components/ui/sidebar";
import { AppSidebar } from "@multica/ui/components/app-sidebar";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { HubSidebar } from "./components/hub-sidebar";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const geistSans = Geist({
variable: "--font-geist-sans",
@ -14,9 +22,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const playfair = Playfair_Display({
variable: "--font-brand",
subsets: ["latin"],
weight: ["400"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Multica",
description: "Distributed AI agent framework",
};
export default function RootLayout({
@ -25,11 +39,26 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={inter.variable}>
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${playfair.variable} antialiased`}
>
{children}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SidebarProvider>
<AppSidebar>
<HubSidebar />
</AppSidebar>
<SidebarInset>
<div className="flex h-dvh overflow-hidden">{children}</div>
</SidebarInset>
</SidebarProvider>
</ThemeProvider>
<Toaster />
</body>
</html>
);

View file

@ -0,0 +1,2 @@
export const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:3000"
export const CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL ?? "http://localhost:4000"

View file

@ -1,7 +1,5 @@
"use client"
import { ComponentExample } from "@multica/ui/components/component-example";
import { Chat } from "./components/chat";
export default function Page() {
return <ComponentExample />;
}
return <Chat />;
}

View file

@ -3,13 +3,17 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
"uuid": "^13.0.0",
"zustand": "catalog:",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"next": "16.1.6",

View file

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

BIN
apps/web/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View file

@ -11,13 +11,13 @@
"multica-profile": "./bin/multica-profile.mjs"
},
"scripts": {
"dev": "tsx src/index.ts",
"agent:cli": "tsx src/agent/cli.ts",
"agent:interactive": "tsx src/agent/interactive-cli.ts",
"agent:profile": "tsx src/agent/profile-cli.ts",
"skills:cli": "tsx src/agent/skills-cli.ts",
"dev:gateway": "tsx --watch src/gateway/main.ts",
"dev:console": "tsx --watch src/console/main.ts",
"dev": "concurrently -n gateway,console,web -c blue,yellow,green \"pnpm dev:gateway\" \"pnpm dev:console\" \"pnpm dev:web\"",
"agent:cli": "tsx --env-file=.env src/agent/cli.ts",
"agent:interactive": "tsx --env-file=.env src/agent/interactive-cli.ts",
"agent:profile": "tsx --env-file=.env src/agent/profile-cli.ts",
"skills:cli": "tsx --env-file=.env src/agent/skills-cli.ts",
"dev:gateway": "tsx --env-file=.env --watch src/gateway/main.ts",
"dev:console": "tsx --env-file=.env --watch src/console/main.ts",
"dev:web": "pnpm --filter @multica/web dev",
"dev:desktop": "pnpm --filter @multica/desktop dev",
"build": "turbo build",
@ -38,6 +38,7 @@
"@types/turndown": "^5.0.6",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"concurrently": "^9.2.1",
"esbuild": "^0.27.2",
"tsx": "^4.21.0",
"turbo": "^2.3.4",

View file

@ -4,6 +4,7 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"dependencies": {

View file

@ -9,24 +9,34 @@
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./components/ui/*": "./src/components/ui/*.tsx",
"./components/markdown": "./src/components/markdown/index.ts",
"./components/markdown/*": "./src/components/markdown/*.tsx",
"./hooks/*": "./src/hooks/*.ts"
},
"dependencies": {
"@multica/store": "workspace:*",
"@base-ui/react": "^1.1.0",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@multica/store": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"linkify-it": "^5.0.0",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^3.7.0",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/linkify-it": "^5.0.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"

View file

@ -0,0 +1,40 @@
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarMenu,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar"
import { ThemeToggle } from "@multica/ui/components/theme-toggle"
interface AppSidebarProps {
children?: React.ReactNode
}
export function AppSidebar({ children }: AppSidebarProps) {
return (
<Sidebar>
<SidebarHeader>
<div className="flex items-center gap-2.5 px-2 py-1">
<img
src="/icon.png"
alt="Multica"
className="size-7 rounded-md"
/>
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
Multica
</span>
</div>
</SidebarHeader>
<SidebarContent>{children}</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<ThemeToggle />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}

View file

@ -0,0 +1,55 @@
"use client";
import { useRef } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { ArrowUpIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@multica/ui/lib/utils";
interface ChatInputProps {
onSubmit?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = () => {
const value = textareaRef.current?.value ?? "";
if (!value.trim()) return;
onSubmit?.(value);
textareaRef.current!.value = "";
// reset height
textareaRef.current!.style.height = "auto";
};
return (
<div className={cn(
"bg-card rounded-xl p-3 border border-border transition-colors",
disabled && "cursor-not-allowed opacity-60"
)}>
<textarea
ref={textareaRef}
rows={2}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => {
e.target.style.height = "auto";
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSubmit();
}
}}
className="w-full resize-none bg-transparent px-1 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
<div className="flex items-center justify-end pt-2">
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,250 @@
import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { cn } from '@multica/ui/lib/utils'
export interface CodeBlockProps {
code: string
language?: string
className?: string
/**
* Render mode affects code block styling:
* - 'terminal': Minimal, keeps control chars visible
* - 'minimal': Clean code, basic styling
* - 'full': Rich styling with background, copy button, etc.
*/
mode?: 'terminal' | 'minimal' | 'full'
}
// Map common aliases to Shiki language names
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'bash',
zsh: 'bash',
yml: 'yaml',
rb: 'ruby',
rs: 'rust',
kt: 'kotlin',
'objective-c': 'objc',
objc: 'objc'
}
// Simple LRU cache for highlighted code
const highlightCache = new Map<string, string>()
const CACHE_MAX_SIZE = 200
function getCacheKey(code: string, lang: string): string {
return `${lang}:${code}`
}
function isValidLanguage(lang: string): lang is BundledLanguage {
const normalized = LANGUAGE_ALIASES[lang] || lang
return normalized in bundledLanguages
}
/**
* CodeBlock - Syntax highlighted code block using Shiki
*
* Uses Shiki dual themes with CSS variables for light/dark switching.
* No JS-based dark mode detection needed theme switching is handled
* entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
*
* @see https://shiki.style/guide/dual-themes
*/
export function CodeBlock({
code,
language = 'text',
className,
mode = 'full'
}: CodeBlockProps): React.JSX.Element {
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [copied, setCopied] = React.useState(false)
// Resolve language alias - keep as string to allow 'text' fallback
const langLower = language.toLowerCase()
const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
React.useEffect(() => {
let cancelled = false
async function highlight(): Promise<void> {
const cacheKey = getCacheKey(code, resolvedLang)
const cached = highlightCache.get(cacheKey)
if (cached) {
if (!cancelled) {
setHighlighted(cached)
setIsLoading(false)
}
return
}
try {
// Use valid language or fallback to plaintext
const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
// Dual themes: Shiki outputs CSS variables for both themes in one pass.
// CSS handles switching via .dark selector (see globals.css).
const html = await codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
})
// Cache the result
if (highlightCache.size >= CACHE_MAX_SIZE) {
const firstKey = highlightCache.keys().next().value
if (firstKey) highlightCache.delete(firstKey)
}
highlightCache.set(cacheKey, html)
if (!cancelled) {
setHighlighted(html)
setIsLoading(false)
}
} catch (error) {
// Fallback to plain text on error
console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
if (!cancelled) {
setHighlighted(null)
setIsLoading(false)
}
}
}
highlight()
return () => {
cancelled = true
}
}, [code, resolvedLang])
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}, [code])
// Terminal mode: raw monospace with minimal styling
if (mode === 'terminal') {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code>{code}</code>
</pre>
)
}
// Minimal mode: just syntax highlighting, no chrome
if (mode === 'minimal') {
if (isLoading || !highlighted) {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code>{code}</code>
</pre>
)
}
return (
<div
className={cn(
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent',
className
)}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)
}
// Full mode: rich styling with header and copy button
return (
<div
className={cn(
'relative group rounded-lg overflow-hidden border bg-muted/30 mb-4 last:mb-0',
className
)}
>
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
</span>
<button
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
>
{copied ? (
<svg
className="w-4 h-4 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
</div>
{/* Code content */}
<div className="p-3 overflow-x-auto">
{isLoading || !highlighted ? (
<pre className="font-mono text-sm whitespace-pre-wrap break-all">
<code>{code}</code>
</pre>
) : (
<div
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)}
</div>
</div>
)
}
/**
* InlineCode - Styled inline code span
* Features: subtle background (3%), subtle border (5%), 75% opacity text
*/
export function InlineCode({
children,
className
}: {
children: React.ReactNode
className?: string
}): React.JSX.Element {
return (
<code
className={cn(
'px-1.5 py-0.5 rounded bg-foreground/[0.03] border border-foreground/[0.05] font-mono text-sm text-foreground/75',
className
)}
>
{children}
</code>
)
}

View file

@ -0,0 +1,301 @@
import * as React from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
/**
* Render modes for markdown content:
*
* - 'terminal': Raw output with minimal formatting, control chars visible
* Best for: Debug output, raw logs, when you want to see exactly what's there
*
* - 'minimal': Clean rendering with syntax highlighting but no extra chrome
* Best for: Chat messages, inline content, when you want readability without clutter
*
* - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
* Best for: Documentation, long-form content, when presentation matters
*/
export type RenderMode = 'terminal' | 'minimal' | 'full'
export interface MarkdownProps {
children: string
/**
* Render mode controlling formatting level
* @default 'minimal'
*/
mode?: RenderMode
className?: string
/**
* Message ID for memoization (optional)
* When provided, memoizes parsed blocks to avoid re-parsing during streaming
*/
id?: string
/**
* Callback when a URL is clicked
*/
onUrlClick?: (url: string) => void
/**
* Callback when a file path is clicked
*/
onFileClick?: (path: string) => void
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
/**
* Create custom components based on render mode
*/
function createComponents(
mode: RenderMode,
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Links: Make clickable with callbacks
a: ({ href, children }) => {
const handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (href) {
// Check if it's a file path
if (FILE_PATH_REGEX.test(href) && onFileClick) {
onFileClick(href)
} else if (onUrlClick) {
onUrlClick(href)
} else {
// Default: open in new window
window.open(href, '_blank', 'noopener,noreferrer')
}
}
}
return (
<a
href={href}
onClick={handleClick}
className="text-primary hover:underline cursor-pointer"
>
{children}
</a>
)
}
}
// Terminal mode: minimal formatting
if (mode === 'terminal') {
return {
...baseComponents,
// No special code handling - just monospace
code: ({ children }) => <code className="font-mono">{children}</code>,
pre: ({ children }) => <pre className="font-mono whitespace-pre-wrap my-2">{children}</pre>,
// Minimal paragraph spacing
p: ({ children }) => <p className="my-1">{children}</p>,
// Simple lists
ul: ({ children }) => <ul className="list-disc list-inside my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside my-1">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
// Plain tables
table: ({ children }) => <table className="my-2 font-mono text-sm">{children}</table>,
th: ({ children }) => <th className="text-left pr-4">{children}</th>,
td: ({ children }) => <td className="pr-4">{children}</td>
}
}
// Minimal mode: clean with syntax highlighting
if (mode === 'minimal') {
return {
...baseComponents,
// Inline code
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
// Block code - use CodeBlock with full mode
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
// Inline code
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Comfortable paragraph spacing
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-2 space-y-1 ps-[16px] pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-2 space-y-1 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Clean tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="min-w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b">{children}</thead>,
th: ({ children }) => (
<th className="text-left py-2 px-3 font-semibold text-muted-foreground">{children}</th>
),
td: ({ children }) => <td className="py-2 px-3 border-b border-border/50">{children}</td>,
// Headings - H1/H2 same size, differentiated by weight
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-5 mb-3">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-4 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-sm font-semibold mt-4 mb-2">{children}</h3>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-2 text-muted-foreground italic">
{children}
</blockquote>
),
// Horizontal rules
hr: () => <hr className="my-4 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>
}
}
// Full mode: rich styling
return {
...baseComponents,
// Full code blocks with copy button
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Rich paragraph spacing
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-3 space-y-1.5 ps-[16px] pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-3 space-y-1.5 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Beautiful tables
table: ({ children }) => (
<div className="my-4 overflow-x-auto rounded-md border">
<table className="min-w-full divide-y divide-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
th: ({ children }) => <th className="text-left py-3 px-4 font-semibold text-sm">{children}</th>,
td: ({ children }) => <td className="py-3 px-4 text-sm">{children}</td>,
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
// Rich headings
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-7 mb-4">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => <h3 className="font-sans text-sm font-semibold mt-5 mb-3">{children}</h3>,
h4: ({ children }) => <h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>,
// Styled blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-foreground/30 bg-muted/30 pl-4 pr-3 py-2 my-3 rounded-r-md">
{children}
</blockquote>
),
// Task lists (GFM)
input: ({ type, checked }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 rounded border-muted-foreground"
/>
)
}
return <input type={type} />
},
// Horizontal rules
hr: () => <hr className="my-6 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-muted-foreground">{children}</del>
}
}
/**
* Markdown - Customizable markdown renderer with multiple render modes
*
* Features:
* - Three render modes: terminal, minimal, full
* - Syntax highlighting via Shiki
* - GFM support (tables, task lists, strikethrough)
* - Clickable links and file paths
* - Memoization for streaming performance
*/
export function Markdown({
children,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick),
[mode, onUrlClick, onFileClick]
)
// Preprocess to convert raw URLs and file paths to markdown links
const processedContent = React.useMemo(() => preprocessLinks(children), [children])
return (
<div className={cn('markdown-content text-sm', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
/**
* MemoizedMarkdown - Optimized for streaming scenarios
*
* Splits content into blocks and memoizes each block separately,
* so only new/changed blocks re-render during streaming.
*/
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
// If id is provided, use it for memoization
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
prevProps.children === nextProps.children &&
prevProps.mode === nextProps.mode
)
}
// Otherwise compare content and mode
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
})
MemoizedMarkdown.displayName = 'MemoizedMarkdown'

View file

@ -0,0 +1,186 @@
import * as React from 'react'
import { Markdown, type RenderMode } from './Markdown'
export interface StreamingMarkdownProps {
content: string
isStreaming: boolean
mode?: RenderMode
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}
interface Block {
content: string
isCodeBlock: boolean
}
/**
* Simple hash function for cache keys
* Uses djb2 algorithm - fast and produces good distribution
*/
function simpleHash(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
/**
* Split content into blocks (paragraphs and code blocks)
*
* Block boundaries:
* - Double newlines (paragraph separators)
* - Code fences (```)
*
* This is intentionally simple - just string scanning, no regex per line.
*/
function splitIntoBlocks(content: string): Block[] {
const blocks: Block[] = []
const lines = content.split('\n')
let currentBlock = ''
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Check for code fence (``` at start of line, optionally followed by language)
if (line.startsWith('```')) {
if (!inCodeBlock) {
// Starting a code block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inCodeBlock = true
currentBlock = line + '\n'
} else {
// Ending a code block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: true })
currentBlock = ''
inCodeBlock = false
}
} else if (inCodeBlock) {
// Inside code block - append line
currentBlock += line + '\n'
} else if (line === '') {
// Empty line outside code block = paragraph boundary
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
} else {
// Regular text line
if (currentBlock) {
currentBlock += '\n' + line
} else {
currentBlock = line
}
}
}
// Flush remaining content
if (currentBlock) {
blocks.push({
content: inCodeBlock ? currentBlock : currentBlock.trim(),
isCodeBlock: inCodeBlock // Unclosed code block = still streaming
})
}
return blocks
}
/**
* Memoized block component
*
* Only re-renders if content or mode changes.
* The key is assigned by the parent based on content hash,
* so identical content won't even attempt to render.
*/
const MemoizedBlock = React.memo(
function Block({
content,
mode,
onUrlClick,
onFileClick
}: {
content: string
mode: RenderMode
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}) {
return (
<Markdown mode={mode} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
},
(prev, next) => {
// Only re-render if content actually changed
return prev.content === next.content && prev.mode === next.mode
}
)
MemoizedBlock.displayName = 'MemoizedBlock'
/**
* StreamingMarkdown - Optimized markdown renderer for streaming content
*
* Splits content into blocks (paragraphs, code blocks) and memoizes each block
* independently. Only the last (active) block re-renders during streaming.
*
* Key insight: Completed blocks get a content-hash as their React key.
* Same content = same key = React skips re-render entirely.
*
* @example
* Content: "Hello\n\n```js\ncode\n```\n\nMore..."
*
* Block 1: "Hello" -> key="block-abc123" -> memoized
* Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
* Block 3: "More..." -> key="active-2" -> re-renders
*/
export function StreamingMarkdown({
content,
isStreaming,
mode = 'minimal',
onUrlClick,
onFileClick
}: StreamingMarkdownProps): React.JSX.Element {
// Split into blocks - memoized to avoid recomputation
// Must be called unconditionally to satisfy Rules of Hooks
const blocks = React.useMemo(
() => (isStreaming ? splitIntoBlocks(content) : []),
[content, isStreaming]
)
// Not streaming - use simple Markdown (no block splitting needed)
if (!isStreaming) {
return (
<Markdown mode={mode} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
}
return (
<>
{blocks.map((block, i) => {
const isLastBlock = i === blocks.length - 1
// Complete blocks use content hash as key -> stable identity -> memoized
// Last block uses "active" prefix -> always re-renders on content change
const key = isLastBlock ? `active-${i}` : `block-${simpleHash(block.content)}`
return (
<MemoizedBlock
key={key}
content={block.content}
mode={mode}
onUrlClick={onUrlClick}
onFileClick={onFileClick}
/>
)
})}
</>
)
}

View file

@ -0,0 +1,4 @@
export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown'
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'

View file

@ -0,0 +1,195 @@
import LinkifyIt from 'linkify-it'
/**
* Linkify - URL and file path detection for markdown preprocessing
*
* Uses linkify-it (12M downloads/week) for battle-tested URL detection,
* plus custom regex for local file paths.
*/
// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
const linkify = new LinkifyIt()
// File path regex - detects /path, ~/path, ./path with common extensions
// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
const FILE_PATH_REGEX =
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
interface DetectedLink {
type: 'url' | 'email' | 'file'
text: string
url: string
start: number
end: number
}
interface CodeRange {
start: number
end: number
}
/**
* Find all code block and inline code ranges in text
* These ranges should be excluded from link detection
*/
function findCodeRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
// Find fenced code blocks (```...```)
const fencedRegex = /```[\s\S]*?```/g
let match
while ((match = fencedRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length })
}
// Find inline code (`...`)
// But skip escaped backticks and code inside fenced blocks
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
while ((match = inlineRegex.exec(text)) !== null) {
const pos = match.index
// Check if this is inside a fenced block
const insideFenced = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideFenced) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
return ranges
}
/**
* Check if a position is inside any code range
*/
function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
return ranges.some((r) => pos >= r.start && pos < r.end)
}
/**
* Check if a link at given position is already a markdown link
* Looks for patterns like [text](url) or [text][ref]
*/
function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
// Check if preceded by ]( which indicates we're inside a markdown link href
// Pattern: [text](URL) - we're checking if URL is our link
const before = text.slice(Math.max(0, linkStart - 2), linkStart)
if (before.endsWith('](')) return true
// Check if preceded by ][ for reference links
if (before.endsWith('][')) return true
// Check if the link text is wrapped in []
// Pattern: [URL](href) - URL is being used as link text
const charBefore = text[linkStart - 1]
const charAfter = text[linkEnd]
if (charBefore === '[' && charAfter === ']') return true
return false
}
/**
* Check if ranges overlap
*/
function rangesOverlap(
a: { start: number; end: number },
b: { start: number; end: number }
): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Detect all links (URLs, emails, file paths) in text
*/
export function detectLinks(text: string): DetectedLink[] {
const links: DetectedLink[] = []
// 1. Detect URLs and emails with linkify-it
const urlMatches = linkify.match(text) || []
for (const match of urlMatches) {
links.push({
type: match.schema === 'mailto:' ? 'email' : 'url',
text: match.text,
url: match.url,
start: match.index,
end: match.lastIndex
})
}
// 2. Detect file paths with custom regex
// Reset regex state
FILE_PATH_REGEX.lastIndex = 0
let fileMatch
while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
const path = fileMatch[1]
if (!path) continue // Skip if no capture group
// Calculate actual start position (after any leading whitespace/punctuation)
const fullMatch = fileMatch[0]
const pathOffset = fullMatch.indexOf(path)
const start = fileMatch.index + pathOffset
// Check for overlaps with URL matches (URLs take precedence)
const pathRange = { start, end: start + path.length }
const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
if (overlapsUrl) continue
links.push({
type: 'file',
text: path,
url: path, // File paths are passed as-is to onFileClick handler
start,
end: start + path.length
})
}
// Sort by position
return links.sort((a, b) => a.start - b.start)
}
/**
* Preprocess text to convert raw URLs and file paths into markdown links
* Skips code blocks and already-linked content
*/
export function preprocessLinks(text: string): string {
// Quick check - if no potential links, return early
if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
return text
}
const codeRanges = findCodeRanges(text)
const links = detectLinks(text)
if (links.length === 0) return text
// Build result, converting raw links to markdown links
let result = ''
let lastIndex = 0
for (const link of links) {
// Skip if inside code block
if (isInsideCode(link.start, codeRanges)) continue
// Skip if already a markdown link
if (isAlreadyLinked(text, link.start, link.end)) continue
// Add text before this link
result += text.slice(lastIndex, link.start)
// Convert to markdown link
result += `[${link.text}](${link.url})`
lastIndex = link.end
}
// Add remaining text
result += text.slice(lastIndex)
return result
}
/**
* Test if text contains any detectable links
* Useful for optimization - skip preprocessing if no links present
*/
export function hasLinks(text: string): boolean {
return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
}

View file

@ -0,0 +1,36 @@
/**
* Spinner - 3x3 grid spinner based on SpinKit Grid
*
* Features:
* - Uses currentColor (inherits text color from parent, typically theme primary)
* - Uses em sizing (scales with font-size)
* - 3x3 grid of cubes with staggered scale animation
* - Pure CSS animation (no JS state)
*
* Usage:
* <Spinner className="text-primary" /> // Uses primary theme color
* <Spinner className="text-muted-foreground" /> // Uses muted color
* <Spinner className="text-xs" /> // Controls size via Tailwind font-size
*/
import { cn } from "@multica/ui/lib/utils"
export interface SpinnerProps {
/** Additional className for styling (color via text-*, size via Tailwind text-*) */
className?: string
}
export function Spinner({ className }: SpinnerProps) {
return (
<span className={cn("spinner", className)} role="status" aria-label="Loading">
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
</span>
)
}

View file

@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -0,0 +1,41 @@
"use client"
import { useTheme } from "next-themes"
import { HugeiconsIcon } from "@hugeicons/react"
import { Sun01Icon, Moon01Icon, ComputerIcon } from "@hugeicons/core-free-icons"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<HugeiconsIcon icon={Sun01Icon} className="dark:hidden" />
<HugeiconsIcon icon={Moon01Icon} className="hidden dark:block" />
<span>Theme</span>
</SidebarMenuButton>
}
/>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onClick={() => setTheme("light")}>
<HugeiconsIcon icon={Sun01Icon} /> Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<HugeiconsIcon icon={Moon01Icon} /> Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<HugeiconsIcon icon={ComputerIcon} /> System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,50 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, toast, type ToasterProps } from "sonner"
import { HugeiconsIcon } from "@hugeicons/react"
import { CheckmarkCircle02Icon, InformationCircleIcon, Alert02Icon, MultiplicationSignCircleIcon, Loading03Icon } from "@hugeicons/core-free-icons"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<HugeiconsIcon icon={CheckmarkCircle02Icon} strokeWidth={2} className="size-4 text-emerald-500" />
),
info: (
<HugeiconsIcon icon={InformationCircleIcon} strokeWidth={2} className="size-4 text-blue-500" />
),
warning: (
<HugeiconsIcon icon={Alert02Icon} strokeWidth={2} className="size-4 text-amber-500" />
),
error: (
<HugeiconsIcon icon={MultiplicationSignCircleIcon} strokeWidth={2} className="size-4 text-red-500" />
),
loading: (
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} className="size-4 text-muted-foreground animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster, toast }

View file

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View file

@ -83,6 +83,9 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--scrollbar-thumb: oklch(0.82 0.003 286);
--scrollbar-thumb-hover: oklch(0.705 0.015 286.067);
--scrollbar-track: transparent;
}
.dark {
@ -117,13 +120,96 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--scrollbar-thumb: oklch(1 0 0 / 15%);
--scrollbar-thumb-hover: oklch(1 0 0 / 30%);
--scrollbar-track: transparent;
}
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
/* @see https://shiki.style/guide/dual-themes */
.shiki,
.shiki span {
color: var(--shiki-light);
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
}
/* Scroll fade hint utilities — mask content edges to hint at scrollable overflow */
.mask-fade-y {
mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
}
.mask-fade-bottom {
mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
body {
@apply bg-background text-foreground;
}
}
/* SPINNER - SpinKit Grid (3x3) */
.spinner {
display: inline-grid;
grid-template-columns: repeat(3, 1fr);
width: 1em;
height: 1em;
gap: 0.08em;
}
.spinner-cube {
background-color: currentColor;
animation: spinner-grid 1.3s infinite ease-in-out;
transform: scale3d(0.5, 0.5, 1);
}
.spinner-cube:nth-child(1) { animation-delay: 0.2s; }
.spinner-cube:nth-child(2) { animation-delay: 0.3s; }
.spinner-cube:nth-child(3) { animation-delay: 0.4s; }
.spinner-cube:nth-child(4) { animation-delay: 0.1s; }
.spinner-cube:nth-child(5) { animation-delay: 0.2s; }
.spinner-cube:nth-child(6) { animation-delay: 0.3s; }
.spinner-cube:nth-child(7) { animation-delay: 0s; }
.spinner-cube:nth-child(8) { animation-delay: 0.1s; }
.spinner-cube:nth-child(9) { animation-delay: 0.2s; }
@keyframes spinner-grid {
0%, 70%, 100% {
transform: scale3d(0.5, 0.5, 1);
}
35% {
transform: scale3d(0, 0, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner-cube {
animation: none;
opacity: 0.7;
}
}

1239
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ import { AppModule } from "./app.module.js";
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.enableCors();
app.useLogger(app.get(Logger));
const port = process.env["PORT"] ?? 4000;