140
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
137
apps/web/app/components/chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/web/app/components/hub-sidebar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
26
apps/web/app/hooks/use-device-id.ts
Normal 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)
|
||||
}
|
||||
44
apps/web/app/hooks/use-gateway.ts
Normal 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 }
|
||||
}
|
||||
19
apps/web/app/hooks/use-hub-init.ts
Normal 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])
|
||||
}
|
||||
75
apps/web/app/hooks/use-hub-store.ts
Normal 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()
|
||||
},
|
||||
}))
|
||||
25
apps/web/app/hooks/use-messages.ts
Normal 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 }
|
||||
}
|
||||
68
apps/web/app/hooks/use-scroll-fade.ts
Normal 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
|
After Width: | Height: | Size: 233 KiB |
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
2
apps/web/app/lib/config.ts
Normal 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"
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
After Width: | Height: | Size: 233 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
15
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
40
packages/ui/src/components/app-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
packages/ui/src/components/chat-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
packages/ui/src/components/markdown/CodeBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
301
packages/ui/src/components/markdown/Markdown.tsx
Normal 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'
|
||||
186
packages/ui/src/components/markdown/StreamingMarkdown.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
4
packages/ui/src/components/markdown/index.ts
Normal 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'
|
||||
195
packages/ui/src/components/markdown/linkify.ts
Normal 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)
|
||||
}
|
||||
36
packages/ui/src/components/spinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
packages/ui/src/components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
41
packages/ui/src/components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
packages/ui/src/components/ui/sonner.tsx
Normal 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 }
|
||||
19
packages/ui/src/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||