Commit 483c835b authored by Admin's avatar Admin

chore: save UI prototype and backend adjustments

parent d64a3f7b
.worktree @ cfbf686a
Subproject commit cfbf686aa98a0df31fd59955bc0b329c0496b4bc
...@@ -8,10 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession ...@@ -8,10 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json
from schemas.common import CompanyScope from schemas.common import CompanyScope
from database import get_db_session from database import get_db_session
from middleware.auth import require_company_scope, get_current_user from middleware.auth import require_company_scope, get_current_user
from models import Company, Issue, Agent, CostEvent, Approval from models import Company, Issue, Agent, CostEvent, Approval
from common.cache import get_redis_cache
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
...@@ -22,25 +25,39 @@ async def get_dashboard_summary( ...@@ -22,25 +25,39 @@ async def get_dashboard_summary(
db: AsyncSession = Depends(get_db_session) db: AsyncSession = Depends(get_db_session)
): ):
"""Get dashboard summary matching DashboardSummary schema.""" """Get dashboard summary matching DashboardSummary schema."""
redis = get_redis_cache().get_client()
cache_key = f"dashboard_summary:{company_id}"
if redis:
try:
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
except Exception:
pass
# 1. Agents count # 1. Agents count
agents_result = await db.execute( agents_result = await db.execute(
select(Agent).where(Agent.company_id == company_id) select(Agent.status, func.count(Agent.id))
.where(Agent.company_id == company_id)
.group_by(Agent.status)
) )
agents = agents_result.scalars().all() agent_counts = dict(agents_result.all())
active_agents = sum(1 for a in agents if a.status == "active") active_agents = agent_counts.get("active", 0)
paused_agents = sum(1 for a in agents if a.status == "paused") paused_agents = agent_counts.get("paused", 0)
error_agents = sum(1 for a in agents if a.status == "error") error_agents = agent_counts.get("error", 0)
running_agents = active_agents running_agents = active_agents
# 2. Tasks count # 2. Tasks count
issues_result = await db.execute( issues_result = await db.execute(
select(Issue).where(Issue.company_id == company_id) select(Issue.status, func.count(Issue.id))
.where(Issue.company_id == company_id)
.group_by(Issue.status)
) )
issues = issues_result.scalars().all() issue_counts = dict(issues_result.all())
open_issues = sum(1 for i in issues if i.status == "open" or i.status == "todo") open_issues = issue_counts.get("open", 0) + issue_counts.get("todo", 0)
in_progress = sum(1 for i in issues if i.status == "in_progress") in_progress = issue_counts.get("in_progress", 0)
blocked = sum(1 for i in issues if i.status == "blocked") blocked = issue_counts.get("blocked", 0)
done = sum(1 for i in issues if i.status == "done") done = issue_counts.get("done", 0)
# 3. Costs # 3. Costs
company_result = await db.execute( company_result = await db.execute(
...@@ -79,7 +96,7 @@ async def get_dashboard_summary( ...@@ -79,7 +96,7 @@ async def get_dashboard_summary(
"total": 0 "total": 0
}) })
return { response_data = {
"companyId": company_id, "companyId": company_id,
"agents": { "agents": {
"active": active_agents, "active": active_agents,
...@@ -107,6 +124,14 @@ async def get_dashboard_summary( ...@@ -107,6 +124,14 @@ async def get_dashboard_summary(
}, },
"runActivity": run_activity "runActivity": run_activity
} }
if redis:
try:
await redis.setex(cache_key, 60, json.dumps(response_data))
except Exception:
pass
return response_data
@router.get("/stats") @router.get("/stats")
......
...@@ -14,41 +14,56 @@ from sqlalchemy.pool import StaticPool ...@@ -14,41 +14,56 @@ from sqlalchemy.pool import StaticPool
from config import settings from config import settings
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncEngine
# Convert sqlite+aiosqlite:// to async URL format # Convert sqlite+aiosqlite:// to async URL format
# aiosqlite expects: sqlite+aiosqlite:///path/to/db.sqlite
# For in-memory: sqlite+aiosqlite:///:memory:
DATABASE_URL = settings.database_url DATABASE_URL = settings.database_url
# Async engine - supports both SQLite and PostgreSQL class DatabaseManager:
if DATABASE_URL.startswith("sqlite+"): """
async_engine = create_async_engine( Singleton class for Database Connection with simple lazy loading.
DATABASE_URL, """
echo=settings.debug, _instance: Optional['DatabaseManager'] = None
poolclass=StaticPool if ":memory:" in DATABASE_URL else None,
connect_args={ def __init__(self):
"check_same_thread": False if DatabaseManager._instance is not None:
} if ":memory:" not in DATABASE_URL else {}, raise RuntimeError("Call get_instance() instead")
)
elif DATABASE_URL.startswith("postgresql+asyncpg"): if DATABASE_URL.startswith("sqlite+"):
async_engine = create_async_engine( self.async_engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=settings.debug, echo=settings.debug,
pool_size=20, poolclass=StaticPool if ":memory:" in DATABASE_URL else None,
max_overflow=10, connect_args={
pool_pre_ping=True, "check_same_thread": False
pool_recycle=3600, } if ":memory:" not in DATABASE_URL else {},
) )
else: elif DATABASE_URL.startswith("postgresql+asyncpg"):
async_engine = create_async_engine(DATABASE_URL, echo=settings.debug) self.async_engine = create_async_engine(
DATABASE_URL,
# Async session factory echo=settings.debug,
AsyncSessionLocal = sessionmaker( pool_size=20,
async_engine, max_overflow=10,
class_=AsyncSession, pool_pre_ping=True,
expire_on_commit=False, pool_recycle=3600,
autocommit=False, )
autoflush=False, else:
) self.async_engine = create_async_engine(DATABASE_URL, echo=settings.debug)
self.AsyncSessionLocal = sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
@classmethod
def get_instance(cls) -> 'DatabaseManager':
if cls._instance is None:
cls._instance = cls()
return cls._instance
class BaseModelMixin: class BaseModelMixin:
def __getattribute__(self, name): def __getattribute__(self, name):
...@@ -73,7 +88,8 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]: ...@@ -73,7 +88,8 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
Dependency generator for FastAPI routes. Dependency generator for FastAPI routes.
Usage: db: AsyncSession = Depends(get_db_session) Usage: db: AsyncSession = Depends(get_db_session)
""" """
session = AsyncSessionLocal() db_manager = DatabaseManager.get_instance()
session = db_manager.AsyncSessionLocal()
try: try:
yield session yield session
await session.commit() await session.commit()
...@@ -89,10 +105,20 @@ get_db_context = asynccontextmanager(get_db_session) ...@@ -89,10 +105,20 @@ get_db_context = asynccontextmanager(get_db_session)
async def init_db(): async def init_db():
"""Create all tables (dev only). For prod use Alembic migrations.""" """Create all tables (dev only). For prod use Alembic migrations."""
async with async_engine.begin() as conn: db_manager = DatabaseManager.get_instance()
async with db_manager.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async def close_db(): async def close_db():
"""Close database connections.""" """Close database connections."""
await async_engine.dispose() db_manager = DatabaseManager.get_instance()
await db_manager.async_engine.dispose()
def __getattr__(name):
if name == 'async_engine':
return DatabaseManager.get_instance().async_engine
if name == 'AsyncSessionLocal':
return DatabaseManager.get_instance().AsyncSessionLocal
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
...@@ -47,11 +47,16 @@ ...@@ -47,11 +47,16 @@
"@paperclipai/adapter-utils": "workspace:*", "@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*", "@paperclipai/shared": "workspace:*",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^3.0.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@types/three": "^0.184.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"gsap": "^3.15.0",
"hermes-paperclip-adapter": "^0.2.0", "hermes-paperclip-adapter": "^0.2.0",
"i18next": "^26.1.0", "i18next": "^26.1.0",
"lexical": "0.35.0", "lexical": "0.35.0",
...@@ -64,20 +69,22 @@ ...@@ -64,20 +69,22 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.1" "tailwind-merge": "^3.4.1",
"three": "^0.184.0",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@storybook/addon-a11y": "10.3.5", "@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5", "@storybook/addon-docs": "10.3.5",
"@storybook/react-vite": "10.3.5", "@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "^4.0.7",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"storybook": "10.3.5",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"storybook": "10.3.5",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.0.5" "vitest": "^3.0.5"
} }
......
This image diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
...@@ -5,6 +5,7 @@ import { Layout } from "./components/Layout"; ...@@ -5,6 +5,7 @@ import { Layout } from "./components/Layout";
import { OnboardingWizard } from "./components/OnboardingWizard"; import { OnboardingWizard } from "./components/OnboardingWizard";
import { CloudAccessGate } from "./components/CloudAccessGate"; import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { LandingPage } from "./pages/LandingPage";
import { Onboarding } from "./pages/Onboarding"; import { Onboarding } from "./pages/Onboarding";
import { DashboardLive } from "./pages/DashboardLive"; import { DashboardLive } from "./pages/DashboardLive";
import { Companies } from "./pages/Companies"; import { Companies } from "./pages/Companies";
...@@ -282,6 +283,7 @@ export function App() { ...@@ -282,6 +283,7 @@ export function App() {
return ( return (
<> <>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} />
<Route path="auth" element={<AuthPage />} /> <Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} /> <Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} /> <Route path="cli-auth/:id" element={<CliAuthPage />} />
...@@ -289,7 +291,7 @@ export function App() { ...@@ -289,7 +291,7 @@ export function App() {
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} /> <Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
<Route element={<CloudAccessGate />}> <Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} /> <Route path="dashboard" element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<Onboarding />} /> <Route path="onboarding" element={<Onboarding />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} /> <Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}> <Route path="instance/settings" element={<Layout />}>
......
import { useGLTF, useAnimations } from '@react-three/drei';
import { useRef, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
export function Astronaut() {
const group = useRef<THREE.Group>(null);
const { scene, animations } = useGLTF('/assets/space/Astronaut.glb');
const { actions } = useAnimations(animations, group);
useEffect(() => {
// Play idle or walk animation if exists in the GLTF
if (actions && Object.keys(actions).length > 0) {
const firstAction = Object.values(actions)[0];
firstAction?.play();
}
}, [actions]);
// Floating animation
useFrame((state) => {
if (group.current) {
group.current.position.y = Math.sin(state.clock.elapsedTime) * 0.1 - 0.5;
group.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
}
});
return (
<group ref={group} position={[2, -0.5, -4]} rotation={[0, -Math.PI / 6, 0]} dispose={null}>
<primitive object={scene} scale={0.5} />
</group>
);
}
useGLTF.preload('/assets/space/Astronaut.glb');
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Image, useScroll } from '@react-three/drei';
import * as THREE from 'three';
export function AstronautHero() {
const astronautRef = useRef<THREE.Mesh>(null);
const spaceshipRef = useRef<THREE.Mesh>(null);
const scroll = useScroll();
useFrame((state) => {
const offset = scroll.offset;
if (astronautRef.current && spaceshipRef.current) {
// 1. Tính toán khoảng cách bay
const flyAwayDistance = 5 + (Math.pow(offset, 3) * 50);
// Vị trí gốc bám theo camera
const baseZ = state.camera.position.z - flyAwayDistance;
const baseX = state.camera.position.x;
const baseY = state.camera.position.y;
// 2. Hoạt ảnh Phi thuyền bay tới
// Ban đầu ở ngoài màn hình bên phải (x=15)
let shipTargetX = 15;
let shipTargetY = -5;
if (offset > 0.05) {
// Tàu bay vào giữa khung hình (dưới chân phi hành gia)
shipTargetX = THREE.MathUtils.lerp(15, 0, Math.min((offset - 0.05) * 10, 1));
shipTargetY = THREE.MathUtils.lerp(-5, -2.5, Math.min((offset - 0.05) * 10, 1));
}
// Tàu lơ lửng nhẹ
const shipFloat = Math.sin(state.clock.elapsedTime * 1.5) * 0.1;
spaceshipRef.current.position.set(baseX + shipTargetX, baseY + shipTargetY + shipFloat, baseZ);
spaceshipRef.current.rotation.copy(state.camera.rotation);
spaceshipRef.current.rotation.z += Math.sin(state.clock.elapsedTime * 0.8) * 0.03;
// 3. Hoạt ảnh Phi hành gia "nhảy" lên tàu
let astroTargetY = -1;
if (offset > 0.15) {
// Hạ dần xuống boong tàu
astroTargetY = THREE.MathUtils.lerp(-1, -1.8, Math.min((offset - 0.15) * 10, 1));
}
// Phi hành gia lơ lửng
const astroFloat = Math.sin(state.clock.elapsedTime * 2) * 0.2;
// Nếu đã lên tàu (offset > 0.2), phi hành gia dao động cùng tàu
const finalAstroY = offset > 0.2 ? astroTargetY + shipFloat : astroTargetY + astroFloat;
astronautRef.current.position.set(baseX, baseY + finalAstroY, baseZ + 0.1); // Z+0.1 để đứng trước tàu
astronautRef.current.rotation.copy(state.camera.rotation);
astronautRef.current.rotation.z += Math.sin(state.clock.elapsedTime) * 0.05;
}
});
return (
<group>
{/* Phi thuyền */}
<Image
ref={spaceshipRef}
url="/assets/space/cute_spaceship_transparent.png"
transparent
position={[15, -5, -5]}
scale={[6, 4]} // Phi thuyền to hơn
/>
{/* Phi hành gia */}
<Image
ref={astronautRef}
url="/assets/space/cute_astronaut_transparent.png"
transparent
position={[0, -1, -5]}
scale={[2.5, 2.5]}
/>
</group>
);
}
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function BlackHole({ position = [0, 0, 0] }: Props) {
const accretionDiskRef = useRef<THREE.Mesh>(null);
const outerDiskRef = useRef<THREE.Mesh>(null);
useFrame(() => {
if (accretionDiskRef.current) {
accretionDiskRef.current.rotation.z += 0.01;
}
if (outerDiskRef.current) {
outerDiskRef.current.rotation.z -= 0.005;
}
});
return (
<group position={position} rotation={[Math.PI / 3, 0, 0]}>
{/* Lõi hố đen (Màu đen tuyệt đối không phản chiếu ánh sáng) */}
<mesh>
<sphereGeometry args={[15, 64, 64]} />
<meshBasicMaterial color="#000000" />
</mesh>
{/* Vành đai ánh sáng (Accretion disk) */}
<mesh ref={accretionDiskRef} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[18, 28, 64]} />
<meshBasicMaterial
color="#ff5500"
side={THREE.DoubleSide}
transparent={true}
opacity={0.8}
/>
</mesh>
{/* Vành đai mờ bên ngoài */}
<mesh ref={outerDiskRef} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[28, 45, 64]} />
<meshBasicMaterial
color="#aa2200"
side={THREE.DoubleSide}
transparent={true}
opacity={0.4}
/>
</mesh>
{/* Ánh sáng phát ra từ hố đen */}
<pointLight intensity={5} distance={100} color="#ff5500" />
</group>
);
}
import { useTexture } from '@react-three/drei';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function Earth({ position = [0, 0, 0] }: Props) {
const meshRef = useRef<THREE.Group>(null);
const earthRef = useRef<THREE.Mesh>(null);
const cloudsRef = useRef<THREE.Mesh>(null);
const [colorMap, cloudsMap] = useTexture([
'/assets/space/2k_earth.jpg',
'/assets/space/2k_earth_clouds.jpg'
]);
useFrame(() => {
if (earthRef.current) {
earthRef.current.rotation.y += 0.0005;
}
if (cloudsRef.current) {
cloudsRef.current.rotation.y += 0.0007; // Clouds move slightly faster
}
});
return (
<group ref={meshRef} position={position}>
{/* Earth sphere */}
<mesh ref={earthRef}>
<sphereGeometry args={[10, 64, 64]} />
<meshStandardMaterial
map={colorMap}
roughness={0.7}
metalness={0.1}
/>
</mesh>
{/* Clouds sphere */}
<mesh ref={cloudsRef} scale={[1.02, 1.02, 1.02]}>
<sphereGeometry args={[10, 64, 64]} />
<meshStandardMaterial
map={cloudsMap}
transparent={true}
opacity={0.6}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
</group>
);
}
import { useTexture } from '@react-three/drei';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function Jupiter({ position = [0, 0, 0] }: Props) {
const meshRef = useRef<THREE.Mesh>(null);
const [colorMap] = useTexture([
'/assets/space/2k_jupiter.jpg'
]);
useFrame(() => {
if (meshRef.current) {
meshRef.current.rotation.y += 0.002;
}
});
return (
<mesh ref={meshRef} position={position}>
<sphereGeometry args={[20, 64, 64]} />
<meshStandardMaterial
map={colorMap}
roughness={0.4}
/>
</mesh>
);
}
import { useTexture } from '@react-three/drei';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function Mars({ position = [0, 0, 0] }: Props) {
const meshRef = useRef<THREE.Mesh>(null);
const [colorMap] = useTexture([
'/assets/space/2k_mars.jpg'
]);
useFrame(() => {
if (meshRef.current) {
meshRef.current.rotation.y += 0.001;
}
});
return (
<mesh ref={meshRef} position={position}>
<sphereGeometry args={[8, 64, 64]} />
<meshStandardMaterial
map={colorMap}
bumpMap={colorMap}
bumpScale={0.03}
roughness={0.9}
/>
</mesh>
);
}
import { useTexture } from '@react-three/drei';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function Moon({ position = [0, -2, -10] }: Props) {
const moonRef = useRef<THREE.Mesh>(null);
// Load the 2K PBR Textures
const [colorMap] = useTexture([
'/assets/space/2k_moon.jpg'
]);
// Rotate slowly
useFrame(() => {
if (moonRef.current) {
moonRef.current.rotation.y += 0.0002;
}
});
return (
<mesh ref={moonRef} position={position} rotation={[0, Math.PI / 2, 0]}>
{/* 64 segments for high resolution sphere */}
<sphereGeometry args={[4, 64, 64]} />
<meshStandardMaterial
map={colorMap}
bumpMap={colorMap}
bumpScale={0.02}
roughness={0.8}
metalness={0.1}
/>
</mesh>
);
}
import { useTexture } from '@react-three/drei';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function Saturn({ position = [0, 0, 0] }: Props) {
const meshRef = useRef<THREE.Group>(null);
const [colorMap, ringMap] = useTexture([
'/assets/space/2k_saturn.jpg',
'/assets/space/2k_saturn_ring_alpha.png'
]);
useFrame(() => {
if (meshRef.current) {
meshRef.current.rotation.y += 0.001;
}
});
return (
<group ref={meshRef} position={position} rotation={[0.4, 0, 0]}>
{/* Planet */}
<mesh>
<sphereGeometry args={[14, 64, 64]} />
<meshStandardMaterial
map={colorMap}
roughness={0.6}
/>
</mesh>
{/* Rings */}
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[18, 30, 64]} />
<meshStandardMaterial
map={ringMap}
alphaMap={ringMap}
transparent={true}
side={THREE.DoubleSide}
opacity={0.8}
/>
</mesh>
</group>
);
}
import { Canvas, useFrame } from '@react-three/fiber';
import { Environment, Sparkles, Stars, ScrollControls, Scroll, useScroll, Text, PerspectiveCamera } from '@react-three/drei';
import { EffectComposer, Bloom, Vignette, Noise } from '@react-three/postprocessing';
import { Suspense, useRef } from 'react';
import * as THREE from 'three';
import { Image } from '@react-three/drei';
import { Moon } from './Moon';
import { Earth } from './Earth';
import { SpinningGalaxy } from './SpinningGalaxy';
import { BlackHole } from './BlackHole';
// Camera controller for Z-Axis diving
function ScrollCamera() {
const scroll = useScroll();
const cameraRef = useRef<THREE.PerspectiveCamera>(null);
useFrame((state) => {
if (!cameraRef.current) return;
const offset = scroll.offset;
// Lao thẳng vào màn hình (Trục Z)
// 0 -> Earth (-20)
// 0.25 -> Moon (-60)
// 0.5 -> Galaxy (-120)
// 0.75 -> BlackHole (-180)
// 1.0 -> Super Galaxy (-240)
const targetZ = THREE.MathUtils.lerp(10, -250, offset);
state.camera.position.z = THREE.MathUtils.lerp(state.camera.position.z, targetZ, 0.05);
state.camera.position.x = 0;
state.camera.position.y = 0;
});
return <PerspectiveCamera ref={cameraRef} makeDefault position={[0, 0, 10]} fov={60} />;
}
// Cô bé và Phi thuyền (2.5D Sprite trên nền 3D)
function HeroCharacter() {
const scroll = useScroll();
const standingRef = useRef<THREE.Mesh>(null);
const plantingRef = useRef<THREE.Mesh>(null);
const spaceshipRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
const offset = scroll.offset;
const camZ = state.camera.position.z;
const float = Math.sin(state.clock.elapsedTime * 2) * 0.2;
// Khoảng cách từ hero tới camera
const distanceToCamera = 5 + (Math.pow(offset, 3) * 50); // Càng về cuối càng bay xa ra
// Vị trí mặc định bám theo camera
const heroZ = camZ - distanceToCamera;
const heroY = -1 + float;
const heroX = 0;
if (standingRef.current && plantingRef.current && spaceshipRef.current) {
standingRef.current.visible = false;
plantingRef.current.visible = false;
spaceshipRef.current.visible = false;
// 0 - 0.1: Lơ lửng trước Trái Đất
if (offset < 0.1) {
standingRef.current.visible = true;
standingRef.current.position.set(heroX, heroY, heroZ);
}
// 0.1 - 0.22: Bay lên Mặt Trăng bằng tàu
else if (offset < 0.22) {
spaceshipRef.current.visible = true;
spaceshipRef.current.position.set(heroX, heroY - 0.5, heroZ);
spaceshipRef.current.rotation.z = Math.sin(state.clock.elapsedTime) * 0.05; // Tàu lắc lư nhẹ
}
// 0.22 - 0.32: Cắm hoa trên bề mặt Mặt trăng (Z: -60)
else if (offset >= 0.22 && offset < 0.32) {
plantingRef.current.visible = true;
// Chốt cứng cô bé trên nóc Mặt trăng
plantingRef.current.position.set(0, 1.5, -55);
}
// > 0.32: Lên tàu bay tới các trạm tiếp theo
else {
spaceshipRef.current.visible = true;
spaceshipRef.current.position.set(heroX, heroY - 0.5, heroZ);
spaceshipRef.current.rotation.z = Math.sin(state.clock.elapsedTime) * 0.05; // Tàu lắc lư
}
}
});
return (
<group>
<Image ref={standingRef} url="/assets/space/anime_girl_standing.png" transparent scale={[3, 3]} />
<Image ref={plantingRef} url="/assets/space/anime_girl_planting.png" transparent scale={[3, 3]} />
<Image ref={spaceshipRef} url="/assets/space/anime_spaceship.png" transparent scale={[5, 2.5]} />
</group>
);
}
export function SpaceScene({ children }: { children?: React.ReactNode }) {
return (
<div className="w-full h-screen bg-[#020111] overflow-hidden">
<Canvas shadows>
<color attach="background" args={['#020111']} />
<Environment preset="night" />
<ambientLight intensity={0.5} />
{/* Hạt bụi vũ trụ */}
<Stars radius={100} depth={50} count={5000} factor={4} saturation={0} fade speed={1} />
<Sparkles count={300} scale={50} size={2} speed={0.4} color="#a8b1ff" />
<ScrollControls pages={5} damping={0.2}>
<ScrollCamera />
<Suspense fallback={null}>
{/* Siêu Nhân 2.5D */}
<HeroCharacter />
{/* Trạm 0: Trái Đất (3D) */}
<Earth position={[0, -2, -20]} />
{/* Trạm 1: Mặt Trăng (3D) */}
<Moon position={[0, -2, -60]} />
{/* Trạm 2: Ngân Hà (3D Particle) */}
<group position={[0, 0, -120]}>
<SpinningGalaxy />
</group>
{/* Trạm 3: Hố Đen (3D Shader/Particle) */}
<group position={[0, 0, -180]}>
<BlackHole />
</group>
{/* Trạm 4: Siêu Ngân Hà (Particle bùng nổ) */}
<group position={[0, 0, -240]}>
<Text position={[0, 5, 0]} fontSize={4} color="#c084fc" anchorX="center" anchorY="middle">
Super Galaxy
</Text>
<Sparkles count={1000} scale={40} size={4} speed={2} color="#c084fc" />
<SpinningGalaxy />
</group>
</Suspense>
{/* Post processing cho 3D đẹp lung linh */}
<EffectComposer>
<Bloom luminanceThreshold={0.5} luminanceSmoothing={0.9} height={300} intensity={0.5} />
<Noise opacity={0.02} />
<Vignette eskil={false} offset={0.1} darkness={1.1} />
</EffectComposer>
{/* HTML Overlay */}
<Scroll html style={{ width: '100%', height: '100%' }}>
{children}
</Scroll>
</ScrollControls>
</Canvas>
</div>
);
}
import { Sparkles } from '@react-three/drei';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface Props {
position?: [number, number, number];
}
export function SpinningGalaxy({ position = [0, 0, 0] }: Props) {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
if (groupRef.current) {
// Xoay toàn bộ dải ngân hà
groupRef.current.rotation.y += 0.002;
groupRef.current.rotation.z += 0.0005;
}
});
return (
<group ref={groupRef} position={position} rotation={[Math.PI / 4, 0, 0]}>
<Sparkles count={4000} scale={100} size={2} speed={0.2} opacity={0.5} color="#8fa3cf" />
<Sparkles count={2000} scale={150} size={4} speed={0.5} opacity={0.3} color="#c084fc" />
<Sparkles count={1000} scale={200} size={6} speed={0.8} opacity={0.6} color="#ffffff" />
<pointLight intensity={2} distance={100} color="#c084fc" />
</group>
);
}
...@@ -13,7 +13,7 @@ import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments"; ...@@ -13,7 +13,7 @@ import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments";
import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-issue-runs"; import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-issue-runs";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes"; import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router"; import { useLiveEventStore } from "../store/useLiveEventStore";
const TOAST_COOLDOWN_WINDOW_MS = 10_000; const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3; const TOAST_COOLDOWN_MAX = 3;
...@@ -924,9 +924,11 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { ...@@ -924,9 +924,11 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pushToast } = useToastActions(); const { pushToast } = useToastActions();
const location = useLocation();
// Zustand store handles the WS connection entirely
const { connect, disconnect } = useLiveEventStore();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 }); const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const pathnameRef = useRef(location.pathname);
const { data: session, status: sessionStatus } = useQuery({ const { data: session, status: sessionStatus } = useQuery({
queryKey: queryKeys.auth.session, queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(), queryFn: () => authApi.getSession(),
...@@ -941,10 +943,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { ...@@ -941,10 +943,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
agentId: null, agentId: null,
}); });
useEffect(() => {
pathnameRef.current = location.pathname;
}, [location.pathname]);
useEffect(() => { useEffect(() => {
currentActorRef.current = { currentActorRef.current = {
userId: currentUserId, userId: currentUserId,
...@@ -955,88 +953,28 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { ...@@ -955,88 +953,28 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (!canConnectSocket || !liveCompanyId) return; if (!canConnectSocket || !liveCompanyId) return;
let closed = false; // Delegate websocket logic to Zustand store
let reconnectAttempt = 0; connect(liveCompanyId, (parsedEvent) => {
let reconnectTimer: number | null = null; // Pass window.location.pathname dynamically so we don't need to hook into React Router's state
let socket: WebSocket | null = null; // This prevents the massive God Context from re-rendering the whole tree on navigation!
handleLiveEvent(
const clearReconnect = () => { queryClient,
if (reconnectTimer !== null) { liveCompanyId,
window.clearTimeout(reconnectTimer); window.location.pathname,
reconnectTimer = null; parsedEvent,
} pushToast,
}; gateRef.current,
{
const scheduleReconnect = () => { userId: currentActorRef.current.userId,
if (closed) return; agentId: currentActorRef.current.agentId,
reconnectAttempt += 1;
const delayMs = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4));
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
const nextSocket = new WebSocket(url);
socket = nextSocket;
nextSocket.onopen = () => {
if (closed || socket !== nextSocket) {
closeSocketQuietly(nextSocket, "stale_connection");
return;
}
if (reconnectAttempt > 0) {
gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS;
}
reconnectAttempt = 0;
};
nextSocket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
try {
const parsed = JSON.parse(raw) as LiveEvent;
handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
userId: currentActorRef.current.userId,
agentId: currentActorRef.current.agentId,
});
} catch {
// Ignore non-JSON payloads.
} }
}; );
});
nextSocket.onerror = () => {
// Wait for onclose to drive the reconnect. Self-closing here is what
// produces the "closed before connection established" browser noise.
};
nextSocket.onclose = () => {
if (socket !== nextSocket) return;
socket = null;
if (closed) return;
scheduleReconnect();
};
};
// Delay initial connect slightly so React StrictMode's double-invoke
// cleanup fires before the WebSocket is created, avoiding the
// "WebSocket closed before connection established" dev-mode error.
const connectTimer = window.setTimeout(connect, 0);
return () => { return () => {
closed = true; disconnect();
window.clearTimeout(connectTimer);
clearReconnect();
const activeSocket = socket;
socket = null;
closeSocketQuietly(activeSocket, "provider_unmount");
}; };
}, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]); }, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey, connect, disconnect]);
return <>{children}</>; return <>{children}</>;
} }
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;0,600;0,700;1,500;1,600&family=Hanken+Grotesk:wght@400;500;600;700&display=swap');
.landing-body {
--night-0: #070d1c;
--night-1: #0c1430;
--night-2: #11204a;
--ink: #f3f1e9;
--ink-soft: rgba(243, 241, 233, 0.74);
--ink-faint: rgba(243, 241, 233, 0.5);
--muted: #8ea4cc;
--line: rgba(243, 241, 233, 0.22);
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Hanken Grotesk", system-ui, sans-serif;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
font-family: var(--sans);
color: var(--ink);
overflow-x: hidden;
background: #060a18;
-webkit-font-smoothing: antialiased;
}
.astronaut-overlay {
position: absolute;
right: -5%;
top: 15%;
width: 50vw;
max-width: 600px;
min-width: 300px;
animation: floatAstro 8s ease-in-out infinite;
filter: contrast(1.1) brightness(1.2);
}
@keyframes floatAstro {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(-2deg); }
}
.landing-body ::selection {
background: var(--muted);
color: #06101f;
}
.grade-bottom {
position: fixed; left: 0; right: 0; bottom: 0; height: 62vh; z-index: 4; pointer-events: none;
background: linear-gradient(180deg, transparent 0%, rgba(6,10,24,0.28) 58%, rgba(6,10,24,0.72) 86%, var(--night-0) 100%);
}
.grade-top {
position: fixed; left: 0; right: 0; top: 0; height: 24vh; z-index: 4; pointer-events: none;
background: linear-gradient(180deg, rgba(6,10,24,0.65), transparent);
}
.vignette {
position: fixed; inset: 0; z-index: 4; pointer-events: none;
background: radial-gradient(120% 100% at 50% 42%, transparent 52%, rgba(4,7,18,0.7) 100%);
}
.topbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
display: flex; align-items: center; justify-content: space-between;
padding: 30px clamp(26px, 5vw, 64px);
}
.brand {
font-family: var(--serif); font-weight: 600; font-size: 1.7rem; letter-spacing: .01em; color: var(--ink);
display: flex; align-items: baseline; gap: 2px; text-decoration: none;
}
.brand sup {
font-family: var(--sans); font-size: .5rem; letter-spacing: .1em; color: var(--ink-faint); position: relative; top: -1.1em;
}
.navlinks {
display: flex; align-items: center; gap: clamp(20px, 2.6vw, 40px);
}
.navlinks a {
font-family: var(--sans); color: var(--ink-soft); text-decoration: none; font-weight: 500; font-size: .84rem; letter-spacing: .04em; transition: color .2s; white-space: nowrap;
}
.navlinks a:hover { color: var(--ink); }
.nav-cta {
font-family: var(--sans); color: var(--ink); text-decoration: none; font-weight: 500; font-size: .84rem; letter-spacing: .03em;
padding: 11px 22px; border: 1px solid var(--line); border-radius: 999px;
background: rgba(243, 241, 233, 0.04); backdrop-filter: blur(8px); white-space: nowrap;
transition: background .3s var(--ease), border-color .3s ease, transform .3s var(--ease);
}
.nav-cta:hover {
background: rgba(243, 241, 233, 0.12); border-color: var(--ink-soft); transform: translateY(-2px);
}
.hero {
position: fixed; z-index: 6; left: 50%; top: 50%; transform: translate(-50%, -54%);
width: min(920px, 92vw); text-align: center; pointer-events: none;
}
.hero > * { pointer-events: auto; }
.eyebrow {
display: inline-flex; align-items: center; gap: 9px; font-family: var(--sans);
font-size: .74rem; font-weight: 600; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-soft);
margin-bottom: 26px;
}
.eyebrow .pip {
width: 7px; height: 7px; border-radius: 50%; background: #cfe0ff; box-shadow: 0 0 10px rgba(207,224,255,0.9);
}
.hero h1 {
font-family: var(--serif); font-weight: 600; line-height: 1.04; letter-spacing: 0.005em;
font-size: clamp(2.6rem, 6.6vw, 5.8rem); color: var(--ink);
text-shadow: 0 2px 12px rgba(6,10,24,0.7), 0 8px 60px rgba(6,10,24,0.6);
}
.hero h1 em { font-style: italic; color: var(--muted); }
.hero .sub {
font-family: var(--sans); color: var(--ink-soft); font-weight: 400;
font-size: clamp(1rem, 1.5vw, 1.14rem); line-height: 1.65; max-width: 50ch; margin: 22px auto 0;
}
.hero .cta-wrap { margin-top: 34px; }
.btn-journey {
display: inline-flex; align-items: center; gap: 12px; font-family: var(--sans); font-weight: 600; font-size: 1rem;
letter-spacing: .02em; color: var(--ink); text-decoration: none; cursor: pointer;
padding: 18px 42px; border-radius: 999px; border: 1px solid var(--line);
background: rgba(243, 241, 233, 0.06); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
box-shadow: 0 10px 40px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12);
transition: transform .3s var(--ease), background .3s ease, border-color .3s ease, box-shadow .3s ease;
}
.btn-journey:hover {
transform: translateY(-3px); background: rgba(243, 241, 233, 0.14); border-color: var(--ink-soft); box-shadow: 0 16px 50px rgba(120,150,220,0.3);
}
.btn-journey .arrow { transition: transform .3s var(--ease); }
.btn-journey:hover .arrow { transform: translateX(4px); }
.meta {
position: fixed; z-index: 6; left: 50%; bottom: 88px; transform: translateX(-50%);
display: flex; align-items: center; gap: clamp(20px, 4vw, 48px);
font-family: var(--sans); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-faint);
white-space: nowrap;
}
.meta .sep { width: 1px; height: 12px; background: var(--line); }
.scroll-hint {
position: fixed; z-index: 6; left: 50%; bottom: 30px; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 10px;
font-family: var(--sans); font-size: .66rem; letter-spacing: .34em; text-transform: uppercase; color: var(--ink-faint);
}
.scroll-hint .line {
width: 1px; height: 34px; background: linear-gradient(var(--ink-faint), transparent); animation: scrollPulse 1.9s ease-in-out infinite;
}
@keyframes scrollPulse {
0%, 100% { opacity: .3; transform: scaleY(.6); }
50% { opacity: 1; transform: scaleY(1); }
}
@media (max-width: 760px) {
.navlinks a:not(.nav-cta) { display: none; }
.hero { top: 48%; }
.meta { display: none; }
}
@media (max-height: 600px) {
.meta { display: none; }
.hero .sub { display: none; }
}
import { Link } from 'react-router-dom';
import { SpaceScene } from '../components/space/SpaceScene';
import { Loader } from '@react-three/drei';
import './LandingPage.css';
export function LandingPage() {
return (
<div className="landing-body w-full h-screen bg-[#060a18] relative">
{/* Navbar - OUTSIDE Canvas to preserve Router context and keep it fixed */}
<header className="topbar z-50">
<Link to="/" className="brand">
The&nbsp;Moon&nbsp;AI<sup>®</sup>
</Link>
<nav className="navlinks">
<Link to="/">Trang chủ</Link>
<a href="#">Studio</a>
<a href="#">Về chúng tôi</a>
<a href="#">Nhật ký</a>
<Link to="/dashboard" className="nav-cta">Bắt đầu hành trình</Link>
</nav>
</header>
<SpaceScene>
{/* --- SCROLL NARRATIVE POINTS --- */}
{/* 0vh - The Start (Moon) */}
<div style={{ position: 'absolute', top: '0vh', width: '100vw', height: '100vh' }}>
<section className="hero">
<p className="eyebrow"><span className="pip"></span> Trạm Xuất Phát · Trái Đất</p>
<h1>Hành trình <br /><em>vạn dặm</em>.</h1>
<p className="sub">
Bắt đầu từ một bước chân nhỏ.
</p>
</section>
{/* Astronaut overlay removed - moved to 3D space */}
{/* 100vh - The Moon */}
<div className="scroll-hint">
Cuộn xuống để du hành<span className="line"></span>
</div>
</div>
{/* 200vh - Mars */}
<div style={{ position: 'absolute', top: '200vh', width: '100vw', height: '100vh' }} className="flex flex-col items-center justify-center pointer-events-none">
<div className="text-center">
<p className="eyebrow"><span className="pip bg-gray-300 shadow-[0_0_10px_rgba(209,213,219,0.9)]"></span> Chặng 1 · Mặt Trăng</p>
<h2 className="text-4xl md:text-6xl font-serif text-white tracking-wide">Trạm Dừng · Mặt Trăng</h2>
<p className="mt-4 text-xl text-gray-300 max-w-lg mx-auto">
Gieo mầm hoa cúc trên vùng đất cằn cỗi. Nơi khởi đầu của sự sống mới.
</p>
</div>
</div>
{/* 400vh - Jupiter */}
<div style={{ position: 'absolute', top: '400vh', width: '100vw', height: '100vh' }} className="flex flex-col items-center justify-center pointer-events-none">
<div className="text-center">
<p className="eyebrow"><span className="pip bg-blue-400 shadow-[0_0_10px_rgba(96,165,250,0.9)]"></span> Chặng 2 · Ngân Hà</p>
<h2 className="text-4xl md:text-6xl font-serif text-white tracking-wide">Vũ Điệu Ngân Hà</h2>
<p className="mt-4 text-xl text-gray-300 max-w-lg mx-auto">
Hàng tỷ tỷ vì sao xoay quanh một trục vô hình. Cội nguồn của mọi sáng tạo.
</p>
</div>
</div>
{/* 600vh - Saturn */}
<div style={{ position: 'absolute', top: '600vh', width: '100vw', height: '100vh' }} className="flex flex-col items-center justify-center pointer-events-none">
<div className="text-center">
<p className="eyebrow"><span className="pip bg-orange-600 shadow-[0_0_10px_rgba(234,88,12,0.9)]"></span> Chặng 3 · Hố Đen</p>
<h2 className="text-4xl md:text-6xl font-serif text-white tracking-wide">Hố Đen Siêu Tân Tinh</h2>
<p className="mt-4 text-xl text-gray-300 max-w-lg mx-auto">
Nơi không gian và thời gian uốn cong. Sức hút tuyệt đối của cái đẹp bí ẩn.
</p>
</div>
</div>
{/* 700vh - The End / Galaxy Exit */}
<div style={{ position: 'absolute', top: '700vh', width: '100vw', height: '100vh' }} className="flex flex-col items-center justify-center pointer-events-none">
<div className="text-center mt-[-100px]">
<p className="eyebrow"><span className="pip bg-purple-400 shadow-[0_0_10px_rgba(192,132,252,0.9)]"></span> Vượt Khỏi Quỹ Đạo</p>
<h1 className="font-serif text-6xl md:text-8xl font-bold tracking-wide text-white drop-shadow-[0_0_40px_rgba(255,255,255,0.8)] mt-4">
Vũ Trụ <br/><em>Trí Tuệ</em>.
</h1>
<p className="font-sans text-xl text-[#e0e7ff] mt-8 max-w-2xl mx-auto drop-shadow-lg">
Giới hạn duy nhất là trí tưởng tượng. Khởi tạo Kỷ Nguyên mới cùng The Moon AI.
</p>
<div className="mt-12 pointer-events-auto">
<a href="/dashboard" className="btn-journey">
Khởi tạo ngay <span className="arrow">→</span>
</a>
</div>
</div>
</div>
</SpaceScene>
<div className="grade-top pointer-events-none z-40"></div>
<div className="grade-bottom pointer-events-none z-40"></div>
<div className="vignette pointer-events-none z-40"></div>
{/* 3D Asset Loader */}
<Loader
containerStyles={{ background: '#03050a' }} // Match space background
innerStyles={{ width: '300px' }}
barStyles={{ height: '2px', background: '#ffffff' }}
dataInterpolation={(p) => `Đang tải trạm không gian... ${p.toFixed(0)}%`}
/>
</div>
);
}
import { create } from 'zustand';
type AgentStatus = 'idle' | 'running' | 'error' | 'offline';
interface AgentStatusState {
agentStatuses: Record<string, AgentStatus>;
setAgentStatus: (agentId: string, status: AgentStatus) => void;
// Local UI state for agents
}
export const useAgentStatusStore = create<AgentStatusState>((set) => ({
agentStatuses: {},
setAgentStatus: (agentId, status) =>
set((state) => ({
agentStatuses: {
...state.agentStatuses,
[agentId]: status
}
})),
}));
import { create } from 'zustand';
interface IssueState {
activeIssueId: string | null;
setActiveIssueId: (id: string | null) => void;
// Can add more local UI state for issues here to prevent prop drilling
}
export const useIssueStore = create<IssueState>((set) => ({
activeIssueId: null,
setActiveIssueId: (id) => set({ activeIssueId: id }),
}));
import { create } from 'zustand';
import type { LiveEvent } from '@paperclipai/shared';
interface LiveEventState {
socket: WebSocket | null;
isConnected: boolean;
lastEvent: LiveEvent | null;
connect: (companyId: string, onMessage: (event: LiveEvent) => void) => void;
disconnect: () => void;
}
export const useLiveEventStore = create<LiveEventState>((set, get) => ({
socket: null,
isConnected: false,
lastEvent: null,
connect: (companyId: string, onMessage: (event: LiveEvent) => void) => {
const currentSocket = get().socket;
if (currentSocket) {
currentSocket.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
const ws = new WebSocket(url);
ws.onopen = () => {
set({ isConnected: true, socket: ws });
};
ws.onmessage = (message) => {
try {
const parsed = JSON.parse(message.data) as LiveEvent;
set({ lastEvent: parsed });
onMessage(parsed);
} catch (err) {
// Ignore JSON parse errors
}
};
ws.onclose = () => {
set({ isConnected: false, socket: null });
// Reconnect logic can be added here or handled by the component using the store
};
ws.onerror = () => {
// Error handling
};
set({ socket: ws });
},
disconnect: () => {
const { socket } = get();
if (socket) {
socket.close();
set({ socket: null, isConnected: false });
}
}
}));
APP_ENV=development
APP_KEY=S9w2NFfX2kl+8Kxdi9sD7waaJB87qFx7swrOssz/CJP4gqUe1jMWgPqra4WVJMKiZI0cLN3xU7gSuy2e39YZkfdmfgb/05M5SaVCp8ItIKeDGjmSdQ4BPfGbVxTvZWnUZwNOYO1bCFb8wiWMHsbt+5yGkh3d/RbG3VtKhGAhHDu9msoeKK/zBZXngE+/XwtHukG5KKgFxJYBHXnN7KuBgD0GwARPiGJkCFzbW2EiFXEpIEPnHtOQFpOFeTNNPKeg0JiZUjpBqY8ImNA3x8GG4mq5ZhGXQUCkjdPcRnYjuZ6iIwj4wr2MGnhJbwo1kf8qgA5hfThzgbV0JNd4MhRT4A==
APP_PORT=13001
API_BASE_PATH=/api/
API_BASE_URL=
PLUGIN_PACKAGE_PREFIX=
DB_LOGGING=off
DB_DIALECT=sqlite
DB_STORAGE=storage/db/nocobase.sqlite
INIT_ROOT_EMAIL=admin@nocobase.com
INIT_ROOT_PASSWORD=admin123
INIT_ROOT_NICKNAME=Super Admin
INIT_ROOT_USERNAME=nocobase
################# NOCOBASE APPLICATION #################
# !!! When `APP_ENV=production`, opening http://localhost:13000/ will show "Not Found".
# !!! It is recommended to use nginx to proxy static files. For example https://github.com/nocobase/nocobase/blob/main/docker/nocobase/nocobase.conf
APP_ENV=development
APP_PORT=20000
APP_KEY=test-key-e2e
SOCKET_PATH=storage/gateway-e2e.sock
__E2E__=true
APPEND_PRESET_BUILT_IN_PLUGINS=mock-collections
API_BASE_PATH=/api/
API_BASE_URL=
PROXY_TARGET_URL=
LOGGER_TRANSPORT=
LOGGER_LEVEL=
LOGGER_BASE_PATH=storage/logs-e2e
################# DATABASE #################
DB_DIALECT=sqlite
DB_STORAGE=storage/db/nocobase-e2e.sqlite
DB_TABLE_PREFIX=
# DB_HOST=localhost
# DB_PORT=5432
# DB_DATABASE=nocobase-e2e
# DB_USER=nocobase
# DB_PASSWORD=nocobase
# DB_LOGGING=on
# DB_UNDERSCORED=false
################# STORAGE (Initialization only) #################
INIT_ROOT_EMAIL=admin@nocobase.com
INIT_ROOT_PASSWORD=admin123
INIT_ROOT_NICKNAME=Super Admin
INIT_ROOT_USERNAME=nocobase
APP_ENV=development
APP_PORT=13000
APP_KEY=test-key
API_BASE_PATH=/api/
API_BASE_URL=
################# DATABASE #################
DB_DIALECT=sqlite
DB_STORAGE=storage/db/nocobase-test.sqlite
# DB_HOST=localhost
# DB_PORT=5432
# DB_DATABASE=nocobase-test
# DB_USER=nocobase
# DB_PASSWORD=nocobase
# DB_LOGGING=on
INIT_ROOT_EMAIL=admin@nocobase.com
INIT_ROOT_PASSWORD=admin123
INIT_ROOT_NICKNAME=Super Admin
INIT_ROOT_USERNAME=nocobase
This diff is collapsed.
# NocoBase
<video width="100%" controls>
<source src="https://github.com/user-attachments/assets/4d11a87b-00e2-48f3-9bf7-389d21072d13" type="video/mp4">
</video>
<p align="center">
<a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
## What is NocoBase
NocoBase is the most extensible AI-powered no-code platform.
Total control. Infinite extensibility. AI collaboration.
Enable your team to adapt quickly and cut costs dramatically.
No years of development. No millions wasted.
Deploy NocoBase in minutes — and take control of everything.
Homepage:
https://www.nocobase.com/
Online Demo:
https://demo.nocobase.com/new
Documents:
https://docs.nocobase.com/
Forum:
https://forum.nocobase.com/
Use Cases:
https://www.nocobase.com/en/blog/tags/customer-stories
## Release Notes
Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary.
## Distinctive features
### 1. Data model-driven, not form/table–driven
Instead of being constrained by forms or tables, NocoBase adopts a data model–driven approach, separating data structure from user interface to unlock unlimited possibilities.
- UI and data structure are fully decoupled
- Multiple blocks and actions can be created for the same table or record in any quantity or form
- Supports the main database, external databases, and third-party APIs as data sources
![model](https://static-docs.nocobase.com/model.png)
### 2. AI employees, integrated into your business systems
Unlike standalone AI demos, NocoBase allows you to embed AI capabilities seamlessly into your interfaces, workflows, and data context, making AI truly useful in real business scenarios.
- Define AI employees for roles such as translator, analyst, researcher, or assistant
- Seamless AI–human collaboration in interfaces and workflows
- Ensure AI usage is secure, transparent, and customizable for your business needs
![AI-employee](https://static-docs.nocobase.com/ai-employee-home.png)
### 3. What you see is what you get, incredibly easy to use
While enabling the development of complex business systems, NocoBase keeps the experience simple and intuitive.
- One-click switch between usage mode and configuration mode
- Pages serve as a canvas to arrange blocks and actions, similar to Notion
- Configuration mode is designed for ordinary users, not just programmers
![wysiwyg](https://static-docs.nocobase.com/wysiwyg.gif)
### 4. Everything is a plugin, designed for extension
Adding more no-code features will never cover every business case. NocoBase is built for extension through its plugin-based microkernel architecture.
- All functionalities are plugins, similar to WordPress
- Plugins are ready to use upon installation
- Pages, blocks, actions, APIs, and data sources can all be extended through custom plugins
![plugins](https://static-docs.nocobase.com/plugins.png)
## Installation
NocoBase supports three installation methods:
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/docker-compose">Installing With Docker (👍Recommended)</a>
Suitable for no-code scenarios, no code to write. When upgrading, just download the latest image and reboot.
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/create-nocobase-app">Installing from create-nocobase-app CLI</a>
The business code of the project is completely independent and supports low-code development.
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/git-clone">Installing from Git source code</a>
If you want to experience the latest unreleased version, or want to participate in the contribution, you need to make changes and debug on the source code, it is recommended to choose this installation method, which requires a high level of development skills, and if the code has been updated, you can git pull the latest code.
## How NocoBase works
<video width="100%" controls>
<source src="https://github.com/user-attachments/assets/8d183b44-9bb5-4792-b08f-bc08fe8dfaaf" type="video/mp4">
</video>
import { generatePlugins, getUmiConfig } from '@nocobase/devtools/umiConfig';
import path from 'path';
import { defineConfig } from 'umi';
const umiConfig = getUmiConfig();
process.env.MFSU_AD = 'none';
process.env.DID_YOU_KNOW = 'none';
const pluginPrefix = (process.env.PLUGIN_PACKAGE_PREFIX || '').split(',').filter((item) => !item.includes('preset')); // 因为现在 preset 是直接引入的,所以不能忽略,如果以后 preset 也是动态插件的形式引入,那么这里可以去掉
const isDevCmd = !!process.env.IS_DEV_CMD;
const appPublicPath = isDevCmd ? '/' : '{{env.APP_PUBLIC_PATH}}';
generatePlugins();
export default defineConfig({
title: 'Loading...',
devtool: process.env.NODE_ENV === 'development' ? 'source-map' : false,
favicons: [`${appPublicPath}favicon_no_exist.ico`], // 设置一个不存在的 favicon,防止显示 Umi 默认的 favicon
metas: [{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }],
links: [{ rel: 'stylesheet', href: `${appPublicPath}global.css` }],
headScripts: [
{
content: isDevCmd
? `
window['__nocobase_public_path__'] = "${process.env.APP_PUBLIC_PATH || '/'}";
window['__nocobase_dev_public_path__'] = "/";
window['__esm_cdn_base_url__'] = '${process.env.ESM_CDN_BASE_URL || ''}';
window['__esm_cdn_suffix__'] = '${process.env.ESM_CDN_SUFFIX || ''}';
`
: `
window['__webpack_public_path__'] = '{{env.CDN_BASE_URL}}';
window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';
window['__nocobase_api_base_url__'] = '{{env.API_BASE_URL}}';
window['__nocobase_api_client_storage_prefix__'] = '{{env.API_CLIENT_STORAGE_PREFIX}}';
window['__nocobase_api_client_storage_type__'] = '{{env.API_CLIENT_STORAGE_TYPE}}';
window['__nocobase_api_client_share_token__'] = {{env.API_CLIENT_SHARE_TOKEN}};
window['__nocobase_ws_url__'] = '{{env.WS_URL}}';
window['__nocobase_ws_path__'] = '{{env.WS_PATH}}';
window['__esm_cdn_base_url__'] = '{{env.ESM_CDN_BASE_URL}}';
window['__esm_cdn_suffix__'] = '{{env.ESM_CDN_SUFFIX}}';
`,
},
{
src: `${appPublicPath}browser-checker.js?v=1`,
},
],
cacheDirectoryPath: process.env.APP_CLIENT_CACHE_DIR || `node_modules/.cache`,
outputPath: path.resolve(__dirname, '../dist/client'),
hash: true,
alias: {
...umiConfig.alias,
},
define: {
...umiConfig.define,
'process.env.USE_REMOTE_PLUGIN': process.env.USE_REMOTE_PLUGIN,
},
proxy: {
...umiConfig.proxy,
},
publicPath: 'auto',
fastRefresh: false, // 热更新会导致 Context 丢失,不开启
mfsu: false,
esbuildMinifyIIFE: true,
// srcTranspiler: 'esbuild', // 不行,各种报错
// mfsu: {
// esbuild: true // 不行,各种报错
// },
// 浏览器兼容性,兼容到 2018 年的浏览器
targets: {
chrome: 69,
edge: 79,
safari: 12,
},
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
codeSplitting: {
jsStrategy: 'depPerChunk',
},
chainWebpack(config, { env }) {
if (env === 'production') {
config.plugin('ignore nocobase plugins').use(require('webpack').IgnorePlugin, [
{
resourceRegExp: new RegExp(pluginPrefix.join('|')),
},
]);
}
return config;
},
extraBabelPlugins: ['react-imported-component/babel'],
routes: [{ path: '/*', component: 'index' }],
});
import { IApi } from 'umi';
export default (api: IApi) => {
api.addRuntimePlugin(() => ['../plugin-routerBase/runtime.ts']);
api.addRuntimePluginKey(() => ['routerBase']);
api.onGenerateFiles(() => {
api.writeTmpFile({
path: '../plugin-routerBase/runtime.ts',
content: `
export function modifyContextOpts(memo) {
return { ...memo, basename: window.routerBase };
};
`,
});
});
};
const basename = window['__nocobase_public_path__'] || '/';
let currentPath = window.location.pathname;
if (currentPath === basename.slice(0, -1)) {
const newUrl = `${window.location.origin}${basename}${window.location.search}${window.location.hash}`;
window.location.replace(newUrl);
} else if (!currentPath.startsWith(basename)) {
let newPath = basename + (currentPath.startsWith('/') ? currentPath.slice(1) : currentPath);
let newUrl = window.location.origin + newPath + window.location.search + window.location.hash;
window.location.replace(newUrl);
}
showLog = true;
function log(m) {
if (window.console && showLog) {
console.log(m);
}
}
function css_browser_selector(u) {
var uaInfo = {},
screens = [320, 480, 640, 768, 1024, 1152, 1280, 1440, 1680, 1920, 2560],
allScreens = screens.length,
ua = u.toLowerCase(),
is = function (t) {
return RegExp(t, 'i').test(ua);
},
version = function (p, n) {
n = n.replace('.', '_');
var i = n.indexOf('_'),
ver = '';
while (i > 0) {
ver += ' ' + p + n.substring(0, i);
i = n.indexOf('_', i + 1);
}
ver += ' ' + p + n;
return ver;
},
g = 'gecko',
w = 'webkit',
c = 'chrome',
f = 'firefox',
s = 'safari',
o = 'opera',
m = 'mobile',
a = 'android',
bb = 'blackberry',
lang = 'lang_',
dv = 'device_',
html = document.documentElement,
b = [
(!/opera|webtv/i.test(ua) && /msie\s(\d+)/.test(ua)) || /trident\/.*rv:([0-9]{1,}[\.0-9]{0,})/.test(ua)
? 'ie ie' + (/trident\/4\.0/.test(ua) ? '8' : RegExp.$1 == '11.0' ? '11' : RegExp.$1)
: is('firefox/')
? g +
' ' +
f +
(/firefox\/((\d+)(\.(\d+))(\.\d+)*)/.test(ua)
? ' ' + f + RegExp.$2 + ' ' + f + RegExp.$2 + '_' + RegExp.$4
: '')
: is('gecko/')
? g
: is('opera')
? o +
(/version\/((\d+)(\.(\d+))(\.\d+)*)/.test(ua)
? ' ' + o + RegExp.$2 + ' ' + o + RegExp.$2 + '_' + RegExp.$4
: /opera(\s|\/)(\d+)\.(\d+)/.test(ua)
? ' ' + o + RegExp.$2 + ' ' + o + RegExp.$2 + '_' + RegExp.$3
: '')
: is('konqueror')
? 'konqueror'
: is('blackberry')
? bb +
(/Version\/(\d+)(\.(\d+)+)/i.test(ua)
? ' ' + bb + RegExp.$1 + ' ' + bb + RegExp.$1 + RegExp.$2.replace('.', '_')
: /Blackberry ?(([0-9]+)([a-z]?))[\/|;]/gi.test(ua)
? ' ' + bb + RegExp.$2 + (RegExp.$3 ? ' ' + bb + RegExp.$2 + RegExp.$3 : '')
: '')
: is('android')
? a +
(/Version\/(\d+)(\.(\d+))+/i.test(ua)
? ' ' + a + RegExp.$1 + ' ' + a + RegExp.$1 + RegExp.$2.replace('.', '_')
: '') +
(/Android (.+); (.+) Build/i.test(ua) ? ' ' + dv + RegExp.$2.replace(/ /g, '_').replace(/-/g, '_') : '')
: is('chrome')
? w +
' ' +
c +
(/chrome\/((\d+)(\.(\d+))(\.\d+)*)/.test(ua)
? ' ' + c + RegExp.$2 + (RegExp.$4 > 0 ? ' ' + c + RegExp.$2 + '_' + RegExp.$4 : '')
: '')
: is('iron')
? w + ' iron'
: is('applewebkit/')
? w +
' ' +
s +
(/version\/((\d+)(\.(\d+))(\.\d+)*)/.test(ua)
? ' ' + s + RegExp.$2 + ' ' + s + RegExp.$2 + RegExp.$3.replace('.', '_')
: / Safari\/(\d+)/i.test(ua)
? RegExp.$1 == '419' || RegExp.$1 == '417' || RegExp.$1 == '416' || RegExp.$1 == '412'
? ' ' + s + '2_0'
: RegExp.$1 == '312'
? ' ' + s + '1_3'
: RegExp.$1 == '125'
? ' ' + s + '1_2'
: RegExp.$1 == '85'
? ' ' + s + '1_0'
: ''
: '')
: is('mozilla/')
? g
: '',
is('android|mobi|mobile|j2me|iphone|ipod|ipad|blackberry|playbook|kindle|silk') ? m : '',
is('j2me')
? 'j2me'
: is('ipad|ipod|iphone')
? (/CPU( iPhone)? OS (\d+[_|\.]\d+([_|\.]\d+)*)/i.test(ua) ? 'ios' + version('ios', RegExp.$2) : '') +
' ' +
(/(ip(ad|od|hone))/gi.test(ua) ? RegExp.$1 : '')
: is('playbook')
? 'playbook'
: is('kindle|silk')
? 'kindle'
: is('playbook')
? 'playbook'
: is('mac')
? 'mac' +
(/mac os x ((\d+)[.|_](\d+))/.test(ua) ? ' mac' + RegExp.$2 + ' mac' + RegExp.$1.replace('.', '_') : '')
: is('win')
? 'win' +
(is('windows nt 6.2')
? ' win8'
: is('windows nt 6.1')
? ' win7'
: is('windows nt 6.0')
? ' vista'
: is('windows nt 5.2') || is('windows nt 5.1')
? ' win_xp'
: is('windows nt 5.0')
? ' win_2k'
: is('windows nt 4.0') || is('WinNT4.0')
? ' win_nt'
: '')
: is('freebsd')
? 'freebsd'
: is('x11|linux')
? 'linux'
: '',
/[; |\[](([a-z]{2})(\-[a-z]{2})?)[)|;|\]]/i.test(ua)
? (lang + RegExp.$2).replace('-', '_') + (RegExp.$3 != '' ? (' ' + lang + RegExp.$1).replace('-', '_') : '')
: '',
is('ipad|iphone|ipod') && !is('safari') ? 'ipad_app' : '',
];
console.debug(ua);
function screenSize() {
var w = window.outerWidth || html.clientWidth;
var h = window.outerHeight || html.clientHeight;
uaInfo.orientation = w < h ? 'portrait' : 'landscape';
html.className = html.className.replace(/ ?orientation_\w+/g, '').replace(/ [min|max|cl]+[w|h]_\d+/g, '');
for (var i = allScreens - 1; i >= 0; i--) {
if (w >= screens[i]) {
uaInfo.maxw = screens[i];
break;
}
}
widthClasses = '';
for (var info in uaInfo) {
widthClasses += ' ' + info + '_' + uaInfo[info];
}
html.className = html.className + widthClasses;
return widthClasses;
}
window.onresize = screenSize;
screenSize();
function retina() {
var r = window.devicePixelRatio > 1;
if (r) {
html.className += ' retina';
} else {
html.className += ' non-retina';
}
}
retina();
var cssbs = b.join(' ') + ' js ';
html.className = (cssbs + html.className.replace(/\b(no[-|_]?)?js\b/g, '')).replace(/^ /, '').replace(/ +/g, ' ');
return cssbs;
}
css_browser_selector(navigator.userAgent);
#root {
width: 100%;
height: 100%;
}
/* width */
.win ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* Track */
.win ::-webkit-scrollbar-track {
background: var(--colorBgScrollTrack);
}
/* Handle */
.win ::-webkit-scrollbar-thumb {
background: var(--colorBgScrollBar);
border-radius: 4px;
}
/* Handle on hover */
.win ::-webkit-scrollbar-thumb:hover {
background: var(--colorBgScrollBarHover);
}
.win ::-webkit-scrollbar-thumb:active {
background: var(--colorBgScrollBarActive);
}
.win .rc-virtual-list-scrollbar-thumb {
background: var(--colorBgScrollBar);
}
.win .rc-virtual-list-scrollbar-thumb:hover {
background: var(--colorBgScrollBarHover);
}
.win .rc-virtual-list-scrollbar-thumb:active {
background: var(--colorBgScrollBarActive);
}
body a {
color: var(--colorPrimaryText);
}
body a:hover {
color: var(--colorPrimaryTextHover);
}
body a:active {
color: var(--colorPrimaryTextActive);
}
.ant-btn-link {
color: var(--colorPrimaryText);
}
.ant-btn-link:hover {
color: var(--colorPrimaryTextHover);
}
.ant-btn-link:active {
color: var(--colorPrimaryTextActive);
}
User-agent: *
Disallow: /
\ No newline at end of file
// @ts-nocheck
import packageMap from './packageMap.json';
function devDynamicImport(packageName: string): Promise<any> {
const fileName = packageMap[packageName];
if (!fileName) {
return Promise.resolve(null);
}
try {
return import(`./packages/${fileName}`)
} catch (error) {
return Promise.resolve(null);
}
}
export default devDynamicImport;
\ No newline at end of file
{
"@nocobase/plugin-acl": "nocobase_plugin_acl.ts",
"@nocobase/plugin-action-bulk-edit": "nocobase_plugin_action_bulk_edit.ts",
"@nocobase/plugin-action-bulk-update": "nocobase_plugin_action_bulk_update.ts",
"@nocobase/plugin-action-custom-request": "nocobase_plugin_action_custom_request.ts",
"@nocobase/plugin-action-duplicate": "nocobase_plugin_action_duplicate.ts",
"@nocobase/plugin-action-export": "nocobase_plugin_action_export.ts",
"@nocobase/plugin-action-import": "nocobase_plugin_action_import.ts",
"@nocobase/plugin-action-print": "nocobase_plugin_action_print.ts",
"@nocobase/plugin-ai": "nocobase_plugin_ai.ts",
"@nocobase/plugin-ai-gigachat": "nocobase_plugin_ai_gigachat.ts",
"@nocobase/plugin-api-doc": "nocobase_plugin_api_doc.ts",
"@nocobase/plugin-api-keys": "nocobase_plugin_api_keys.ts",
"@nocobase/plugin-async-task-manager": "nocobase_plugin_async_task_manager.ts",
"@nocobase/plugin-auth": "nocobase_plugin_auth.ts",
"@nocobase/plugin-auth-sms": "nocobase_plugin_auth_sms.ts",
"@nocobase/plugin-backup-restore": "nocobase_plugin_backup_restore.ts",
"@nocobase/plugin-backups": "nocobase_plugin_backups.ts",
"@nocobase/plugin-block-grid-card": "nocobase_plugin_block_grid_card.ts",
"@nocobase/plugin-block-iframe": "nocobase_plugin_block_iframe.ts",
"@nocobase/plugin-block-list": "nocobase_plugin_block_list.ts",
"@nocobase/plugin-block-markdown": "nocobase_plugin_block_markdown.ts",
"@nocobase/plugin-block-multi-step-form": "nocobase_plugin_block_multi_step_form.ts",
"@nocobase/plugin-block-template": "nocobase_plugin_block_template.ts",
"@nocobase/plugin-block-tree": "nocobase_plugin_block_tree.ts",
"@nocobase/plugin-block-workbench": "nocobase_plugin_block_workbench.ts",
"@nocobase/plugin-calendar": "nocobase_plugin_calendar.ts",
"@nocobase/plugin-client": "nocobase_plugin_client.ts",
"@nocobase/plugin-collection-fdw": "nocobase_plugin_collection_fdw.ts",
"@nocobase/plugin-collection-sql": "nocobase_plugin_collection_sql.ts",
"@nocobase/plugin-collection-tree": "nocobase_plugin_collection_tree.ts",
"@nocobase/plugin-comments": "nocobase_plugin_comments.ts",
"@nocobase/plugin-custom-variables": "nocobase_plugin_custom_variables.ts",
"@nocobase/plugin-data-source-main": "nocobase_plugin_data_source_main.ts",
"@nocobase/plugin-data-source-manager": "nocobase_plugin_data_source_manager.ts",
"@nocobase/plugin-data-visualization": "nocobase_plugin_data_visualization.ts",
"@nocobase/plugin-data-visualization-echarts": "nocobase_plugin_data_visualization_echarts.ts",
"@nocobase/plugin-departments": "nocobase_plugin_departments.ts",
"@nocobase/plugin-embed": "nocobase_plugin_embed.ts",
"@nocobase/plugin-environment-variables": "nocobase_plugin_environment_variables.ts",
"@nocobase/plugin-error-handler": "nocobase_plugin_error_handler.ts",
"@nocobase/plugin-field-attachment-url": "nocobase_plugin_field_attachment_url.ts",
"@nocobase/plugin-field-china-region": "nocobase_plugin_field_china_region.ts",
"@nocobase/plugin-field-code": "nocobase_plugin_field_code.ts",
"@nocobase/plugin-field-formula": "nocobase_plugin_field_formula.ts",
"@nocobase/plugin-field-m2m-array": "nocobase_plugin_field_m2m_array.ts",
"@nocobase/plugin-field-markdown-vditor": "nocobase_plugin_field_markdown_vditor.ts",
"@nocobase/plugin-field-sequence": "nocobase_plugin_field_sequence.ts",
"@nocobase/plugin-field-sort": "nocobase_plugin_field_sort.ts",
"@nocobase/plugin-file-manager": "nocobase_plugin_file_manager.ts",
"@nocobase/plugin-file-previewer-office": "nocobase_plugin_file_previewer_office.ts",
"@nocobase/plugin-flow-engine": "nocobase_plugin_flow_engine.ts",
"@nocobase/plugin-form-drafts": "nocobase_plugin_form_drafts.ts",
"@nocobase/plugin-gantt": "nocobase_plugin_gantt.ts",
"@nocobase/plugin-graph-collection-manager": "nocobase_plugin_graph_collection_manager.ts",
"@nocobase/plugin-kanban": "nocobase_plugin_kanban.ts",
"@nocobase/plugin-license": "nocobase_plugin_license.ts",
"@nocobase/plugin-locale-tester": "nocobase_plugin_locale_tester.ts",
"@nocobase/plugin-localization": "nocobase_plugin_localization.ts",
"@nocobase/plugin-logger": "nocobase_plugin_logger.ts",
"@nocobase/plugin-map": "nocobase_plugin_map.ts",
"@nocobase/plugin-mobile": "nocobase_plugin_mobile.ts",
"@nocobase/plugin-mock-collections": "nocobase_plugin_mock_collections.ts",
"@nocobase/plugin-multi-app-manager": "nocobase_plugin_multi_app_manager.ts",
"@nocobase/plugin-multi-keyword-filter": "nocobase_plugin_multi_keyword_filter.ts",
"@nocobase/plugin-notification-email": "nocobase_plugin_notification_email.ts",
"@nocobase/plugin-notification-in-app-message": "nocobase_plugin_notification_in_app_message.ts",
"@nocobase/plugin-notification-manager": "nocobase_plugin_notification_manager.ts",
"@nocobase/plugin-public-forms": "nocobase_plugin_public_forms.ts",
"@nocobase/plugin-system-settings": "nocobase_plugin_system_settings.ts",
"@nocobase/plugin-text-copy": "nocobase_plugin_text_copy.ts",
"@nocobase/plugin-theme-editor": "nocobase_plugin_theme_editor.ts",
"@nocobase/plugin-ui-schema-storage": "nocobase_plugin_ui_schema_storage.ts",
"@nocobase/plugin-ui-templates": "nocobase_plugin_ui_templates.ts",
"@nocobase/plugin-user-data-sync": "nocobase_plugin_user_data_sync.ts",
"@nocobase/plugin-users": "nocobase_plugin_users.ts",
"@nocobase/plugin-verification": "nocobase_plugin_verification.ts",
"@nocobase/plugin-workflow": "nocobase_plugin_workflow.ts",
"@nocobase/plugin-workflow-action-trigger": "nocobase_plugin_workflow_action_trigger.ts",
"@nocobase/plugin-workflow-aggregate": "nocobase_plugin_workflow_aggregate.ts",
"@nocobase/plugin-workflow-cc": "nocobase_plugin_workflow_cc.ts",
"@nocobase/plugin-workflow-custom-action-trigger": "nocobase_plugin_workflow_custom_action_trigger.ts",
"@nocobase/plugin-workflow-date-calculation": "nocobase_plugin_workflow_date_calculation.ts",
"@nocobase/plugin-workflow-delay": "nocobase_plugin_workflow_delay.ts",
"@nocobase/plugin-workflow-dynamic-calculation": "nocobase_plugin_workflow_dynamic_calculation.ts",
"@nocobase/plugin-workflow-javascript": "nocobase_plugin_workflow_javascript.ts",
"@nocobase/plugin-workflow-json-query": "nocobase_plugin_workflow_json_query.ts",
"@nocobase/plugin-workflow-json-variable-mapping": "nocobase_plugin_workflow_json_variable_mapping.ts",
"@nocobase/plugin-workflow-loop": "nocobase_plugin_workflow_loop.ts",
"@nocobase/plugin-workflow-mailer": "nocobase_plugin_workflow_mailer.ts",
"@nocobase/plugin-workflow-manual": "nocobase_plugin_workflow_manual.ts",
"@nocobase/plugin-workflow-notification": "nocobase_plugin_workflow_notification.ts",
"@nocobase/plugin-workflow-parallel": "nocobase_plugin_workflow_parallel.ts",
"@nocobase/plugin-workflow-request": "nocobase_plugin_workflow_request.ts",
"@nocobase/plugin-workflow-request-interceptor": "nocobase_plugin_workflow_request_interceptor.ts",
"@nocobase/plugin-workflow-response-message": "nocobase_plugin_workflow_response_message.ts",
"@nocobase/plugin-workflow-sql": "nocobase_plugin_workflow_sql.ts",
"@nocobase/plugin-workflow-variable": "nocobase_plugin_workflow_variable.ts"
}
\ No newline at end of file
export { default } from '@nocobase/plugin-acl/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-bulk-edit/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-bulk-update/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-custom-request/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-duplicate/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-export/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-import/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-action-print/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-ai/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-ai-gigachat/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-api-doc/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-api-keys/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-async-task-manager/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-auth/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-auth-sms/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-backup-restore/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-backups/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-grid-card/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-iframe/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-list/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-markdown/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-multi-step-form/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-template/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-tree/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-block-workbench/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-calendar/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-client/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-collection-fdw/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-collection-sql/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-collection-tree/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-comments/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-custom-variables/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-data-source-main/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-data-source-manager/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-data-visualization/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-data-visualization-echarts/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-departments/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-embed/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-environment-variables/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-error-handler/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-attachment-url/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-china-region/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-code/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-formula/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-m2m-array/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-markdown-vditor/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-sequence/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-field-sort/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-file-manager/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-file-previewer-office/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-flow-engine/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-form-drafts/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-gantt/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-graph-collection-manager/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-kanban/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-license/client';
\ No newline at end of file
export { default } from '@nocobase/plugin-locale-tester/client';
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment