Initial commit: AI Video Assistant fullstack platform.
Add pipecat-based backend with WebRTC/WS voice routes, Next.js frontend, and Docker Compose orchestration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
35
backend/.env.example
Normal file
35
backend/.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# 复制为 .env 并填入真实值。
|
||||
# 国产栈走 OpenAI 兼容协议:每类服务给一个 base_url + api_key + 模型名即可。
|
||||
|
||||
# ---- LLM:DeepSeek(OpenAI 兼容,直连云端,只需 key) ----
|
||||
LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
LLM_API_KEY=sk-your-deepseek-key
|
||||
LLM_MODEL=deepseek-chat
|
||||
|
||||
# ---- STT:SenseVoice / FunASR ----
|
||||
# 需要本地起一个 OpenAI 兼容的语音转写服务(/v1/audio/transcriptions),
|
||||
# 例如用 funasr / sherpa-onnx / speaches 包一层。下面填那个服务地址。
|
||||
STT_BASE_URL=http://localhost:8001/v1
|
||||
STT_API_KEY=local
|
||||
STT_MODEL=sensevoice
|
||||
|
||||
# ---- TTS:CosyVoice ----
|
||||
# 同样需要本地起一个 OpenAI 兼容的 TTS 服务(/v1/audio/speech)。
|
||||
TTS_BASE_URL=http://localhost:8002/v1
|
||||
TTS_API_KEY=local
|
||||
TTS_MODEL=cosyvoice
|
||||
TTS_VOICE=中文女
|
||||
|
||||
# ---- Realtime 模式(可选,先不接也行) ----
|
||||
REALTIME_API_KEY=
|
||||
REALTIME_MODEL=gpt-realtime
|
||||
|
||||
# ---- 数据库(Postgres) ----
|
||||
# 本地直连;docker compose 里则用 postgres:5432
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
|
||||
|
||||
# ---- 服务监听 & 跨域 ----
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
# 前端开发地址,允许跨域
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# aiortc(WebRTC)和 onnxruntime(Silero VAD)需要的系统库
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ffmpeg \
|
||||
libopus0 \
|
||||
libvpx-dev \
|
||||
libsrtp2-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
104
backend/README.md
Normal file
104
backend/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# AI Video Assistant — 后端引擎
|
||||
|
||||
参考 [dograh](../dograh),用 **pipecat** 作语音引擎的自建后端。目标是逐步长成
|
||||
类 dograh 平台,但**同时支持 WebRTC 和 WS 两种音频输出**。
|
||||
|
||||
## 双输出架构(核心)
|
||||
|
||||
pipecat 把"管线"和"输出方式"解耦:同一条 `STT→LLM→TTS` 管线可挂不同 transport。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
浏览器 ──WebRTC──► /ws/voice ──┤ │
|
||||
│ run_pipeline(transport, cfg): │
|
||||
自定义/话务 ─WS──► /ws/stream ─┤ input→STT→LLM→TTS→output │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **WebRTC**(`/ws/voice`):浏览器,低延迟,带 NAT 穿透。`SmallWebRTCTransport`
|
||||
- **WS**(`/ws/stream`):裸音频流,服务端/话务/自定义客户端,无 ICE/TURN。`FastAPIWebsocketTransport`
|
||||
|
||||
加第三种输出(如 Twilio 电话)= 在 `services/pipecat/transports.py` 再加一个 `build_xxx_transport` + serializer,管线一行不用改。
|
||||
|
||||
## 目录结构(对齐 dograh 的 `api/`,便于生长)
|
||||
|
||||
```
|
||||
ai-video-backend/
|
||||
├── app.py # FastAPI 入口,挂路由 + CORS
|
||||
├── config.py # 读 .env,所有 provider 接入点
|
||||
├── models.py # AssistantConfig(对齐前端 AssistantForm)
|
||||
├── routes/ # 一个文件一组端点(对齐 dograh routes/)
|
||||
│ ├── health.py
|
||||
│ ├── voice_webrtc.py # WebRTC 信令
|
||||
│ └── voice_ws.py # WS 裸音频流
|
||||
├── services/
|
||||
│ └── pipecat/ # 引擎(对齐 dograh services/pipecat/)
|
||||
│ ├── service_factory.py # 建 STT/LLM/TTS(加 provider 在此)
|
||||
│ ├── transports.py # transport 工厂(加输出方式在此)
|
||||
│ └── pipeline.py # 管线拼装与运行(transport 无关)
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
└── .env.example
|
||||
|
||||
# 平台长大后会再加(对齐 dograh):
|
||||
# db/ SQLAlchemy 模型 + 会话(助手/对话/向量)
|
||||
# schemas/ pydantic 请求响应
|
||||
# tasks/ 后台任务(转写、报表)—— 配 redis
|
||||
# services/storage.py 录音存储 —— 配 minio
|
||||
```
|
||||
|
||||
## 国产栈(全走 OpenAI 兼容,换栈只改 .env)
|
||||
|
||||
| 类型 | 默认 | 接入 |
|
||||
|------|------|------|
|
||||
| LLM | DeepSeek | 云端直连,只需 key |
|
||||
| STT | SenseVoice / FunASR | 本地 OpenAI 兼容转写服务 |
|
||||
| TTS | CosyVoice | 本地 OpenAI 兼容 TTS 服务 |
|
||||
|
||||
## 本地运行(用 uv,Python 3.12)
|
||||
|
||||
```bash
|
||||
cd ai-video/backend
|
||||
uv venv # 按 .python-version 用 3.12
|
||||
|
||||
# 阶段 A:只验证存储/CRUD(不装 pipecat,秒级)
|
||||
uv pip install fastapi "uvicorn[standard]" sqlalchemy asyncpg greenlet python-dotenv pydantic loguru
|
||||
# 阶段 B:做语音时再装全量(含 pipecat,需 3.10+)
|
||||
# uv pip install -r requirements.txt
|
||||
|
||||
cp .env.example .env # CRUD 阶段只需 DATABASE_URL;语音再填模型 key
|
||||
# 起 Postgres:在 ai-video/ 下 docker compose up -d postgres
|
||||
.venv/bin/uvicorn app:app --reload --port 8000
|
||||
```
|
||||
|
||||
> pipecat 相关代码用**惰性导入**,所以阶段 A 不装 pipecat 也能启动并跑 `/api/*` 与 `/health`;
|
||||
> 只有真正连 `/ws/voice`、`/ws/stream` 时才需要全量依赖。
|
||||
>
|
||||
> 交互式 API 文档:启动后访问 http://localhost:8000/docs(手动戳 CRUD、定 schema 用)。
|
||||
|
||||
## Docker(ai-video/docker-compose.yaml)—— 调试主路径
|
||||
|
||||
api 服务挂了源码 + `--reload`,前端用 npm dev + HMR,改代码都即时生效。
|
||||
|
||||
```bash
|
||||
cd ai-video
|
||||
docker compose up # 前台起 pg + api(:8000)+ ui(:3000),日志直出
|
||||
docker compose up -d # 后台起;看日志 docker compose logs -f api
|
||||
docker compose down # 停止全部
|
||||
|
||||
# 可选:对象存储 / 后台任务
|
||||
docker compose --profile data up # + rustfs(S3) / redis
|
||||
# 可选:公网部署(WebRTC 需 TURN)
|
||||
docker compose --profile remote up -d
|
||||
```
|
||||
|
||||
> 首次 `up` 会构建 api 镜像(装全量 `requirements.txt`,含 pipecat,较慢)。
|
||||
> 之后改 Python 代码靠 `--reload` 热更新,不用重建;只有改 `requirements.txt` 才 `docker compose build api`。
|
||||
|
||||
## 待联调 / TODO
|
||||
|
||||
- [ ] `pip install` 后跑通,核对 pipecat 版本的服务/transport 构造参数(代码内有注释)
|
||||
- [ ] 起本地 SenseVoice / CosyVoice 的 OpenAI 兼容服务
|
||||
- [ ] `realtime` 模式(目前只 `pipeline` 级联)
|
||||
- [ ] 前端 `DebugVoicePanel` 接 `/ws/voice`(抄 dograh `useWebSocketRTC.tsx`)
|
||||
- [ ] 加 DB 后:助手配置入库(目前随请求内联)
|
||||
48
backend/app.py
Normal file
48
backend/app.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""FastAPI 入口。挂载路由,放行前端跨域,启动时建表。
|
||||
|
||||
启动: uvicorn app:app --reload --port 8000
|
||||
|
||||
路由分组(对齐 dograh 的 routes/ 结构):
|
||||
/health 健康检查
|
||||
/api/assistants 助手 CRUD
|
||||
/api/credentials 模型凭证 CRUD(key 打码)
|
||||
/ws/voice WebRTC 输出(浏览器)
|
||||
/ws/stream WS 输出(裸音频流)
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import config
|
||||
import uvicorn
|
||||
from db.session import init_db
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from routes import assistants, credentials, health, voice_webrtc, voice_ws
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await init_db() # MVP:启动建表;表稳定后切 alembic
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="AI Video Assistant 平台 - 后端", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(assistants.router)
|
||||
app.include_router(credentials.router)
|
||||
app.include_router(voice_webrtc.router)
|
||||
app.include_router(voice_ws.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app:app", host=config.HOST, port=config.PORT, reload=True)
|
||||
43
backend/config.py
Normal file
43
backend/config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""集中读取环境变量。所有 provider 的接入点都在这里,改栈只改 .env。"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _split(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
# ---- LLM(DeepSeek 等,OpenAI 兼容) ----
|
||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1")
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "deepseek-chat")
|
||||
|
||||
# ---- STT(SenseVoice / FunASR,OpenAI 兼容) ----
|
||||
STT_BASE_URL = os.getenv("STT_BASE_URL", "http://localhost:8001/v1")
|
||||
STT_API_KEY = os.getenv("STT_API_KEY", "local")
|
||||
STT_MODEL = os.getenv("STT_MODEL", "sensevoice")
|
||||
|
||||
# ---- TTS(CosyVoice,OpenAI 兼容) ----
|
||||
TTS_BASE_URL = os.getenv("TTS_BASE_URL", "http://localhost:8002/v1")
|
||||
TTS_API_KEY = os.getenv("TTS_API_KEY", "local")
|
||||
TTS_MODEL = os.getenv("TTS_MODEL", "cosyvoice")
|
||||
TTS_VOICE = os.getenv("TTS_VOICE", "中文女")
|
||||
|
||||
# ---- Realtime(可选) ----
|
||||
REALTIME_API_KEY = os.getenv("REALTIME_API_KEY", "")
|
||||
REALTIME_MODEL = os.getenv("REALTIME_MODEL", "gpt-realtime")
|
||||
|
||||
# ---- 数据库(Postgres) ----
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/postgres",
|
||||
)
|
||||
|
||||
# ---- 服务 ----
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "8000"))
|
||||
CORS_ORIGINS = _split(os.getenv("CORS_ORIGINS", "http://localhost:3000"))
|
||||
0
backend/db/__init__.py
Normal file
0
backend/db/__init__.py
Normal file
56
backend/db/models.py
Normal file
56
backend/db/models.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""数据表定义(SQLAlchemy 2.0)。
|
||||
|
||||
两张表,职责分离(见设计):
|
||||
- ProviderCredential:模型凭证(key 明文存,同 dograh,靠 DB 访问控制兜底;读时打码)
|
||||
- Assistant:助手配置,**只存模型/音色的"选项名",不嵌 key**
|
||||
|
||||
助手运行时再用 kind 去 ProviderCredential 取真 key(services/config_resolver.py)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProviderCredential(Base):
|
||||
"""模型资源凭证。字段对齐前端 ComponentsModelsPage 的 ModelResource。"""
|
||||
|
||||
__tablename__ = "provider_credentials"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True) # model_xxx
|
||||
name: Mapped[str] = mapped_column(String(128), default="") # 资源名称,如 "DeepSeek-V3"
|
||||
model_id: Mapped[str] = mapped_column(String(128), default="") # 模型ID,如 "deepseek-chat"
|
||||
type: Mapped[str] = mapped_column(String(16), index=True) # LLM|ASR|TTS|Realtime|Embedding
|
||||
interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini
|
||||
api_url: Mapped[str] = mapped_column(String(512), default="")
|
||||
api_key: Mapped[str] = mapped_column(String(512), default="") # 明文
|
||||
# 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Assistant(Base):
|
||||
__tablename__ = "assistants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True) # asst_xxx
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
greeting: Mapped[str] = mapped_column(String(2048), default="")
|
||||
prompt: Mapped[str] = mapped_column(String(8192), default="")
|
||||
runtime_mode: Mapped[str] = mapped_column(String(16), default="pipeline")
|
||||
# 模型/音色的"选项名",不是 key
|
||||
model: Mapped[str] = mapped_column(String(128), default="")
|
||||
asr: Mapped[str] = mapped_column(String(128), default="")
|
||||
voice: Mapped[str] = mapped_column(String(128), default="")
|
||||
enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
29
backend/db/session.py
Normal file
29
backend/db/session.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""异步数据库引擎 + 会话。
|
||||
|
||||
- engine / SessionLocal:全局单例
|
||||
- get_session:FastAPI 依赖,按请求注入一个会话
|
||||
- init_db:启动时建表(MVP 用 create_all;表结构稳定后切 alembic 迁移,对齐 dograh)
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import config
|
||||
from db.models import Base
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
engine = create_async_engine(config.DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
55
backend/models.py
Normal file
55
backend/models.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""请求/配置数据模型。
|
||||
|
||||
分两层(重要):
|
||||
- AssistantConfig:**运行时**配置,含真 key,只在后端内部流转,绝不返回前端。
|
||||
由 config_resolver 从 DB 组装(或信令内联传入)。
|
||||
- schemas.py 里的 *Request/*Response:面向前端的 DTO(key 打码)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
RuntimeMode = Literal["pipeline", "realtime"]
|
||||
|
||||
|
||||
class AssistantConfig(BaseModel):
|
||||
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
|
||||
|
||||
name: str = "未命名助手"
|
||||
greeting: str = "您好,我是 AI 视频助手,请问有什么可以帮您?"
|
||||
prompt: str = "你是一个有帮助的助手。"
|
||||
runtimeMode: RuntimeMode = "pipeline"
|
||||
|
||||
# 模型/音色选项
|
||||
model: str = "" # LLM
|
||||
asr: str = "" # STT
|
||||
voice: str = "" # TTS 音色
|
||||
realtimeModel: str = ""
|
||||
|
||||
enableInterrupt: bool = True
|
||||
|
||||
# ---- 运行时连接信息(服务端注入,不来自浏览器) ----
|
||||
# 为空时,service_factory 会回退到 config.py 的 .env 默认值。
|
||||
llm_api_key: str = ""
|
||||
llm_base_url: str = ""
|
||||
stt_api_key: str = ""
|
||||
stt_base_url: str = ""
|
||||
tts_api_key: str = ""
|
||||
tts_base_url: str = ""
|
||||
|
||||
|
||||
class SignalingOffer(BaseModel):
|
||||
"""WS 信令里 offer 消息的 payload。
|
||||
|
||||
推荐用 assistant_id(浏览器只传 id,key 在服务端解析);
|
||||
inline_config 仅用于调试/无库场景。
|
||||
"""
|
||||
|
||||
pc_id: str | None = None
|
||||
sdp: str
|
||||
type: str
|
||||
assistant_id: str | None = None
|
||||
inline_config: AssistantConfig | None = None
|
||||
16
backend/requirements.txt
Normal file
16
backend/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
# 薄后端依赖。语音引擎用 pipecat 官方库。
|
||||
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
||||
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
||||
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
||||
pipecat-ai[webrtc,silero,openai]~=0.0.60
|
||||
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-dotenv
|
||||
pydantic
|
||||
loguru
|
||||
|
||||
# 存储:Postgres(SQLAlchemy 2.0 异步 + asyncpg 驱动)
|
||||
sqlalchemy[asyncio]>=2.0
|
||||
asyncpg
|
||||
greenlet # SQLAlchemy 异步运行时必需(部分平台不会自动带上)
|
||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
87
backend/routes/assistants.py
Normal file
87
backend/routes/assistants.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。
|
||||
|
||||
助手配置不含 key,所以无需打码。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from db.models import Assistant
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import AssistantOut, AssistantUpsert
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
|
||||
|
||||
|
||||
def _to_out(a: Assistant) -> AssistantOut:
|
||||
return AssistantOut(
|
||||
id=a.id,
|
||||
name=a.name,
|
||||
greeting=a.greeting,
|
||||
prompt=a.prompt,
|
||||
runtime_mode=a.runtime_mode, # type: ignore[arg-type]
|
||||
model=a.model,
|
||||
asr=a.asr,
|
||||
voice=a.voice,
|
||||
enable_interrupt=a.enable_interrupt,
|
||||
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AssistantOut])
|
||||
async def list_assistants(session: AsyncSession = Depends(get_session)):
|
||||
rows = (
|
||||
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
|
||||
).scalars().all()
|
||||
return [_to_out(a) for a in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=AssistantOut)
|
||||
async def create_assistant(
|
||||
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
a = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **body.model_dump())
|
||||
session.add(a)
|
||||
await session.commit()
|
||||
await session.refresh(a)
|
||||
return _to_out(a)
|
||||
|
||||
|
||||
@router.get("/{assistant_id}", response_model=AssistantOut)
|
||||
async def get_assistant(
|
||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
a = await session.get(Assistant, assistant_id)
|
||||
if not a:
|
||||
raise HTTPException(404, "助手不存在")
|
||||
return _to_out(a)
|
||||
|
||||
|
||||
@router.put("/{assistant_id}", response_model=AssistantOut)
|
||||
async def update_assistant(
|
||||
assistant_id: str,
|
||||
body: AssistantUpsert,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
a = await session.get(Assistant, assistant_id)
|
||||
if not a:
|
||||
raise HTTPException(404, "助手不存在")
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(a, k, v)
|
||||
await session.commit()
|
||||
await session.refresh(a)
|
||||
return _to_out(a)
|
||||
|
||||
|
||||
@router.delete("/{assistant_id}")
|
||||
async def delete_assistant(
|
||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
a = await session.get(Assistant, assistant_id)
|
||||
if not a:
|
||||
raise HTTPException(404, "助手不存在")
|
||||
await session.delete(a)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
106
backend/routes/credentials.py
Normal file
106
backend/routes/credentials.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""模型资源凭证 CRUD。前端 ComponentsModelsPage 对接这里。
|
||||
|
||||
字段对齐前端 ModelResource。读:api_key 打码;写:占位符表示不改(写时哨兵)。
|
||||
设默认时清掉同 type 其它默认。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from db.models import ProviderCredential
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import CredentialOut, CredentialUpsert
|
||||
from services.masking import mask, resolve_incoming_key
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/credentials", tags=["credentials"])
|
||||
|
||||
|
||||
def _to_out(c: ProviderCredential) -> CredentialOut:
|
||||
return CredentialOut(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
model_id=c.model_id,
|
||||
type=c.type,
|
||||
interface_type=c.interface_type,
|
||||
api_url=c.api_url,
|
||||
api_key=mask(c.api_key), # 永远打码
|
||||
is_default=c.is_default,
|
||||
)
|
||||
|
||||
|
||||
async def _clear_other_defaults(session: AsyncSession, type_: str, keep_id: str):
|
||||
await session.execute(
|
||||
update(ProviderCredential)
|
||||
.where(ProviderCredential.type == type_, ProviderCredential.id != keep_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[CredentialOut])
|
||||
async def list_credentials(session: AsyncSession = Depends(get_session)):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(ProviderCredential).order_by(ProviderCredential.type)
|
||||
)
|
||||
).scalars().all()
|
||||
return [_to_out(c) for c in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=CredentialOut)
|
||||
async def create_credential(
|
||||
body: CredentialUpsert, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
c = ProviderCredential(
|
||||
id=f"model_{uuid.uuid4().hex[:12]}",
|
||||
name=body.name,
|
||||
model_id=body.model_id,
|
||||
type=body.type,
|
||||
interface_type=body.interface_type,
|
||||
api_url=body.api_url,
|
||||
api_key=resolve_incoming_key(body.api_key, ""),
|
||||
is_default=body.is_default,
|
||||
)
|
||||
session.add(c)
|
||||
if c.is_default:
|
||||
await _clear_other_defaults(session, c.type, c.id)
|
||||
await session.commit()
|
||||
await session.refresh(c)
|
||||
return _to_out(c)
|
||||
|
||||
|
||||
@router.put("/{cred_id}", response_model=CredentialOut)
|
||||
async def update_credential(
|
||||
cred_id: str,
|
||||
body: CredentialUpsert,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
c = await session.get(ProviderCredential, cred_id)
|
||||
if not c:
|
||||
raise HTTPException(404, "凭证不存在")
|
||||
c.name = body.name
|
||||
c.model_id = body.model_id
|
||||
c.type = body.type
|
||||
c.interface_type = body.interface_type
|
||||
c.api_url = body.api_url
|
||||
c.is_default = body.is_default
|
||||
# 写时哨兵:打码占位符 → 保留旧 key
|
||||
c.api_key = resolve_incoming_key(body.api_key, c.api_key)
|
||||
if c.is_default:
|
||||
await _clear_other_defaults(session, c.type, c.id)
|
||||
await session.commit()
|
||||
await session.refresh(c)
|
||||
return _to_out(c)
|
||||
|
||||
|
||||
@router.delete("/{cred_id}")
|
||||
async def delete_credential(
|
||||
cred_id: str, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
c = await session.get(ProviderCredential, cred_id)
|
||||
if not c:
|
||||
raise HTTPException(404, "凭证不存在")
|
||||
await session.delete(c)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
8
backend/routes/health.py
Normal file
8
backend/routes/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
117
backend/routes/voice_webrtc.py
Normal file
117
backend/routes/voice_webrtc.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""WebRTC 输出:SmallWebRTC 信令握手。
|
||||
|
||||
参考 dograh 的 webrtc_signaling.py,砍掉鉴权/配额/DB/org/ICE 过滤策略/TURN。
|
||||
握手消息:
|
||||
client → {type:"offer", payload:{pc_id, sdp, type, config}}
|
||||
server → {type:"answer", payload:{pc_id, sdp, type}}
|
||||
both → {type:"ice-candidate", payload:{pc_id, candidate:{...}}}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from loguru import logger
|
||||
from models import AssistantConfig, SignalingOffer
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
||||
|
||||
# 注意:pipecat / aiortc 都是重依赖(语音才用),改成函数内"惰性导入",
|
||||
# 这样不装 pipecat 也能启动后端、验证 CRUD。语音真正用到时才加载。
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ice_servers():
|
||||
from aiortc import RTCIceServer
|
||||
|
||||
# 本地只用 STUN;公网部署在此追加 TURN(参考 dograh get_ice_servers)
|
||||
return [RTCIceServer(urls="stun:stun.l.google.com:19302")]
|
||||
|
||||
|
||||
@router.websocket("/ws/voice")
|
||||
async def voice_signaling(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
peers: dict = {}
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_json()
|
||||
if message.get("type") == "offer":
|
||||
await _handle_offer(websocket, message.get("payload", {}), peers)
|
||||
elif message.get("type") == "ice-candidate":
|
||||
await _handle_ice(message.get("payload", {}), peers)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebRTC 信令断开")
|
||||
except Exception as e:
|
||||
logger.error(f"WebRTC 信令出错: {e}")
|
||||
finally:
|
||||
for pc in peers.values():
|
||||
await pc.disconnect()
|
||||
|
||||
|
||||
async def _resolve_config(offer: SignalingOffer) -> AssistantConfig:
|
||||
"""优先用 assistant_id 从 DB 解析(含真 key);否则用调试内联配置。"""
|
||||
if offer.assistant_id:
|
||||
async with SessionLocal() as session:
|
||||
return await resolve_runtime_config(session, offer.assistant_id)
|
||||
if offer.inline_config:
|
||||
return offer.inline_config
|
||||
raise ValueError("offer 缺少 assistant_id 或 inline_config")
|
||||
|
||||
|
||||
async def _handle_offer(websocket, payload, peers):
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from services.pipecat.pipeline import run_pipeline
|
||||
from services.pipecat.transports import build_webrtc_transport
|
||||
|
||||
offer = SignalingOffer(**payload)
|
||||
pc_id = offer.pc_id
|
||||
|
||||
if pc_id and pc_id in peers:
|
||||
pc = peers[pc_id]
|
||||
await pc.renegotiate(sdp=offer.sdp, type=offer.type, restart_pc=False)
|
||||
else:
|
||||
cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连
|
||||
pc = SmallWebRTCConnection(ice_servers=_ice_servers())
|
||||
if pc_id:
|
||||
pc._pc_id = pc_id
|
||||
await pc.initialize(sdp=offer.sdp, type=offer.type)
|
||||
peers[pc.pc_id] = pc
|
||||
|
||||
@pc.event_handler("closed")
|
||||
async def _on_closed(conn: SmallWebRTCConnection):
|
||||
peers.pop(conn.pc_id, None)
|
||||
|
||||
# 后台跑管线:WebRTC transport + 解析出的运行时配置
|
||||
transport = build_webrtc_transport(pc)
|
||||
asyncio.create_task(run_pipeline(transport, cfg))
|
||||
|
||||
answer = pc.get_answer()
|
||||
if websocket.application_state == WebSocketState.CONNECTED:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "answer",
|
||||
"payload": {
|
||||
"pc_id": answer["pc_id"],
|
||||
"sdp": answer["sdp"],
|
||||
"type": answer["type"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _handle_ice(payload, peers):
|
||||
from aiortc.sdp import candidate_from_sdp
|
||||
|
||||
pc_id = payload.get("pc_id")
|
||||
candidate_data = payload.get("candidate")
|
||||
pc = peers.get(pc_id) if pc_id else None
|
||||
if not pc or not candidate_data:
|
||||
return
|
||||
try:
|
||||
candidate = candidate_from_sdp(candidate_data.get("candidate", ""))
|
||||
candidate.sdpMid = candidate_data.get("sdpMid")
|
||||
candidate.sdpMLineIndex = candidate_data.get("sdpMLineIndex")
|
||||
await pc.add_ice_candidate(candidate)
|
||||
except Exception as e:
|
||||
logger.error(f"添加 ICE candidate 失败: {e}")
|
||||
50
backend/routes/voice_ws.py
Normal file
50
backend/routes/voice_ws.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""WS 输出:裸 WebSocket 音频流(第二种输出方式)。
|
||||
|
||||
比 WebRTC 简单——没有 SDP/ICE/STUN/TURN,一条 WS 直接收发音频帧。
|
||||
适合:服务端对接、话务网关、自定义客户端、调试。
|
||||
|
||||
约定:连接建立后,**第一条文本消息**发 JSON 启动参数:
|
||||
{"assistant_id": "asst_xxx"} # 推荐:key 服务端解析
|
||||
{"inline_config": {...AssistantConfig}} # 调试:内联
|
||||
之后的二进制消息即音频帧(protobuf,与 transports.py serializer 对应)。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# pipecat 重依赖,惰性导入(见 voice_webrtc.py 说明)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _resolve_start_config(raw: str) -> AssistantConfig:
|
||||
data = json.loads(raw)
|
||||
if data.get("assistant_id"):
|
||||
async with SessionLocal() as session:
|
||||
return await resolve_runtime_config(session, data["assistant_id"])
|
||||
if data.get("inline_config"):
|
||||
return AssistantConfig(**data["inline_config"])
|
||||
raise ValueError("启动参数缺少 assistant_id 或 inline_config")
|
||||
|
||||
|
||||
@router.websocket("/ws/stream")
|
||||
async def voice_stream(websocket: WebSocket):
|
||||
from services.pipecat.pipeline import run_pipeline
|
||||
from services.pipecat.transports import build_ws_transport
|
||||
|
||||
await websocket.accept()
|
||||
try:
|
||||
cfg = await _resolve_start_config(await websocket.receive_text())
|
||||
transport = build_ws_transport(websocket)
|
||||
# 直接 await:管线持续读这条 WS 的音频帧,直到对端断开
|
||||
await run_pipeline(transport, cfg)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WS 音频流断开")
|
||||
except Exception as e:
|
||||
logger.error(f"WS 音频流出错: {e}")
|
||||
66
backend/schemas.py
Normal file
66
backend/schemas.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""面向前端的请求/响应 DTO。与 DB 模型解耦,**响应里的 key 一律打码**。
|
||||
|
||||
凭证 DTO 字段对齐前端 ComponentsModelsPage 的 ModelResource:
|
||||
JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_case,
|
||||
靠 Pydantic alias 自动互转。FastAPI 响应默认 by_alias=True,所以出参也是 camelCase。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
RuntimeMode = Literal["pipeline", "realtime"]
|
||||
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding"]
|
||||
InterfaceType = Literal["openai", "xfyun", "dashscope", "gemini"]
|
||||
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
"""JSON camelCase ↔ Python snake_case。protected_namespaces 关掉以允许 model_id。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
# ---------- 助手 ----------
|
||||
class AssistantUpsert(CamelModel):
|
||||
name: str
|
||||
greeting: str = ""
|
||||
prompt: str = ""
|
||||
runtime_mode: RuntimeMode = "pipeline"
|
||||
model: str = ""
|
||||
asr: str = ""
|
||||
voice: str = ""
|
||||
enable_interrupt: bool = True
|
||||
|
||||
|
||||
class AssistantOut(AssistantUpsert):
|
||||
id: str
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
# ---------- 模型凭证(对齐前端 ModelResource) ----------
|
||||
class CredentialUpsert(CamelModel):
|
||||
name: str = "" # 资源名称
|
||||
model_id: str = "" # 模型ID
|
||||
type: ModelType # LLM/ASR/TTS/Realtime/Embedding
|
||||
interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini
|
||||
api_url: str = ""
|
||||
api_key: str = "" # 写时:占位符/空表示不改
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class CredentialOut(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
model_id: str
|
||||
type: str
|
||||
interface_type: str
|
||||
api_url: str
|
||||
api_key: str # 读时:打码后的值
|
||||
is_default: bool
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
64
backend/services/config_resolver.py
Normal file
64
backend/services/config_resolver.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""assistant_id → 运行时配置(把真 key 在服务端组装好)。
|
||||
|
||||
浏览器只传 assistant_id;真 key 在这里从 provider_credentials 取出注入。
|
||||
取不到凭证记录时,降级用 .env 默认值(开发期零配置仍能跑)。
|
||||
"""
|
||||
|
||||
import config
|
||||
from db.models import Assistant, ProviderCredential
|
||||
from models import AssistantConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _get_credential(
|
||||
session: AsyncSession, type_: str, name: str = ""
|
||||
) -> ProviderCredential | None:
|
||||
"""取某类(LLM/ASR/TTS)凭证:优先按资源名匹配,否则取该类默认。"""
|
||||
stmt = select(ProviderCredential).where(ProviderCredential.type == type_)
|
||||
if name:
|
||||
# 助手按资源名引用(如 model="DeepSeek-V3");命中则用它
|
||||
named = (
|
||||
await session.execute(stmt.where(ProviderCredential.name == name).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if named:
|
||||
return named
|
||||
stmt = stmt.order_by(
|
||||
ProviderCredential.is_default.desc(), ProviderCredential.id.asc()
|
||||
).limit(1)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def resolve_runtime_config(
|
||||
session: AsyncSession, assistant_id: str
|
||||
) -> AssistantConfig:
|
||||
"""加载助手 + 解析凭证,产出可直接交给管线的运行时配置(含真 key)。
|
||||
|
||||
type 映射:LLM→大模型, ASR→语音识别, TTS→语音合成。
|
||||
"""
|
||||
assistant = await session.get(Assistant, assistant_id)
|
||||
if assistant is None:
|
||||
raise ValueError(f"助手不存在: {assistant_id}")
|
||||
|
||||
llm = await _get_credential(session, "LLM", assistant.model)
|
||||
stt = await _get_credential(session, "ASR", assistant.asr)
|
||||
tts = await _get_credential(session, "TTS")
|
||||
|
||||
return AssistantConfig(
|
||||
name=assistant.name,
|
||||
greeting=assistant.greeting,
|
||||
prompt=assistant.prompt,
|
||||
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||
enableInterrupt=assistant.enable_interrupt,
|
||||
# 模型/音色:凭证的模型ID优先,否则助手里填的
|
||||
model=(llm.model_id if llm else assistant.model),
|
||||
asr=(stt.model_id if stt else assistant.asr),
|
||||
voice=assistant.voice,
|
||||
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
|
||||
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),
|
||||
llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL),
|
||||
stt_api_key=(stt.api_key if stt else config.STT_API_KEY),
|
||||
stt_base_url=(stt.api_url if stt else config.STT_BASE_URL),
|
||||
tts_api_key=(tts.api_key if tts else config.TTS_API_KEY),
|
||||
tts_base_url=(tts.api_url if tts else config.TTS_BASE_URL),
|
||||
)
|
||||
27
backend/services/masking.py
Normal file
27
backend/services/masking.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""API Key 打码 / 写时哨兵(抄 dograh masking.py + merge.py 思路)。
|
||||
|
||||
- mask:返回前端时把真 key 变成 sk-****1234,真 key 永不出后端
|
||||
- is_masked:判断前端回传的是不是打码占位符
|
||||
- resolve_incoming_key:前端回传若是占位符 → 保留旧值;否则用新值
|
||||
"""
|
||||
|
||||
MASK_VISIBLE_TAIL = 4
|
||||
|
||||
|
||||
def mask(api_key: str) -> str:
|
||||
if not api_key:
|
||||
return ""
|
||||
if len(api_key) <= MASK_VISIBLE_TAIL:
|
||||
return "****"
|
||||
return f"{api_key[:2]}****{api_key[-MASK_VISIBLE_TAIL:]}"
|
||||
|
||||
|
||||
def is_masked(value: str) -> bool:
|
||||
return "****" in (value or "")
|
||||
|
||||
|
||||
def resolve_incoming_key(incoming: str | None, stored: str) -> str:
|
||||
"""写入时决定最终 key:占位符/空 → 保留旧;否则用新。"""
|
||||
if incoming is None or incoming == "" or is_masked(incoming):
|
||||
return stored
|
||||
return incoming
|
||||
0
backend/services/pipecat/__init__.py
Normal file
0
backend/services/pipecat/__init__.py
Normal file
67
backend/services/pipecat/pipeline.py
Normal file
67
backend/services/pipecat/pipeline.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""管线核心:给定一个 transport + 配置,跑完整的语音闭环。
|
||||
|
||||
关键设计:**transport 由调用方传入**,管线本身不关心是 WebRTC 还是 WS。
|
||||
这就是"同时支持多种输出"的落点——加输出方式不用动这里。
|
||||
|
||||
对应 dograh 的 pipeline_builder.py + run_pipeline.py(已砍掉 workflow 引擎/DB/录音/指标)。
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.pipecat.service_factory import create_services
|
||||
|
||||
from pipecat.frames.frames import EndFrame, TTSSpeakFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||
|
||||
|
||||
async def run_pipeline(transport, cfg: AssistantConfig) -> None:
|
||||
"""在给定 transport 上构建并运行管线,直到连接结束。
|
||||
|
||||
Args:
|
||||
transport: 任意 pipecat transport(WebRTC / WS / 电话…),
|
||||
只要有 .input() / .output() / event_handler 即可。
|
||||
cfg: 助手配置(随请求内联传入)。
|
||||
"""
|
||||
logger.info(f"启动管线: assistant={cfg.name} mode={cfg.runtimeMode}")
|
||||
|
||||
stt, llm, tts = create_services(cfg)
|
||||
|
||||
context = OpenAILLMContext(messages=[{"role": "system", "content": cfg.prompt}])
|
||||
context_aggregator = llm.create_context_aggregator(context)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
context_aggregator.user(),
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
context_aggregator.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
allow_interruptions=cfg.enableInterrupt,
|
||||
enable_metrics=False,
|
||||
),
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(_transport, _client):
|
||||
if cfg.greeting:
|
||||
await task.queue_frame(TTSSpeakFrame(cfg.greeting))
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(_transport, _client):
|
||||
logger.info("对端断开,结束管线")
|
||||
await task.queue_frame(EndFrame())
|
||||
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
await runner.run(task)
|
||||
logger.info("管线已结束")
|
||||
53
backend/services/pipecat/service_factory.py
Normal file
53
backend/services/pipecat/service_factory.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""创建 STT / LLM / TTS 服务。
|
||||
|
||||
对应 dograh 的 service_factory.py,但只留一套国产栈(OpenAI 兼容),
|
||||
按 provider 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。
|
||||
"""
|
||||
|
||||
import config
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import OpenAISTTService
|
||||
from pipecat.services.openai.tts import OpenAITTSService
|
||||
|
||||
|
||||
def create_stt(cfg: AssistantConfig):
|
||||
"""SenseVoice / FunASR 等,走 OpenAI 兼容的 /v1/audio/transcriptions。
|
||||
|
||||
连接信息优先用 cfg(由 config_resolver 从 DB 注入),为空回退 .env 默认。
|
||||
"""
|
||||
return OpenAISTTService(
|
||||
api_key=cfg.stt_api_key or config.STT_API_KEY,
|
||||
base_url=cfg.stt_base_url or config.STT_BASE_URL,
|
||||
model=cfg.asr or config.STT_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def create_llm(cfg: AssistantConfig):
|
||||
"""DeepSeek 等,走 OpenAI 兼容的 /v1/chat/completions。"""
|
||||
return OpenAILLMService(
|
||||
api_key=cfg.llm_api_key or config.LLM_API_KEY,
|
||||
base_url=cfg.llm_base_url or config.LLM_BASE_URL,
|
||||
model=cfg.model or config.LLM_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def create_tts(cfg: AssistantConfig):
|
||||
"""CosyVoice 等,走 OpenAI 兼容的 /v1/audio/speech。"""
|
||||
return OpenAITTSService(
|
||||
api_key=cfg.tts_api_key or config.TTS_API_KEY,
|
||||
base_url=cfg.tts_base_url or config.TTS_BASE_URL,
|
||||
model=config.TTS_MODEL,
|
||||
voice=cfg.voice or config.TTS_VOICE,
|
||||
)
|
||||
|
||||
|
||||
def create_services(cfg: AssistantConfig):
|
||||
logger.info(
|
||||
f"创建服务: stt={cfg.asr or config.STT_MODEL} "
|
||||
f"llm={cfg.model or config.LLM_MODEL} "
|
||||
f"tts={cfg.voice or config.TTS_VOICE}"
|
||||
)
|
||||
return create_stt(cfg), create_llm(cfg), create_tts(cfg)
|
||||
54
backend/services/pipecat/transports.py
Normal file
54
backend/services/pipecat/transports.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Transport 工厂——管线与"输出方式"解耦的关键。
|
||||
|
||||
同一条 STT→LLM→TTS 管线,可以挂在不同 transport 上:
|
||||
- WebRTC:浏览器,低延迟,带 NAT 穿透 -> build_webrtc_transport
|
||||
- WS: 裸音频流,服务端/话务/自定义客户端,简单 -> build_ws_transport
|
||||
|
||||
未来加电话(Twilio/Vonage)只是再加一个 build_xxx_transport + 对应 serializer。
|
||||
对应 dograh 的 transport_setup.py(WebRTC)+ 各 telephony provider 的 transport.py(WS)。
|
||||
"""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
|
||||
# WebRTC
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
|
||||
|
||||
# 裸 WS 音频流
|
||||
from pipecat.transports.network.fastapi_websocket import (
|
||||
FastAPIWebsocketTransport,
|
||||
FastAPIWebsocketParams,
|
||||
)
|
||||
from pipecat.serializers.protobuf import ProtobufFrameSerializer
|
||||
|
||||
|
||||
def _base_params() -> dict:
|
||||
"""两种 transport 共享的音频参数。"""
|
||||
return dict(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(), # 本地 VAD,打断功能依赖它
|
||||
)
|
||||
|
||||
|
||||
def build_webrtc_transport(connection: SmallWebRTCConnection) -> SmallWebRTCTransport:
|
||||
return SmallWebRTCTransport(
|
||||
webrtc_connection=connection,
|
||||
params=TransportParams(**_base_params()),
|
||||
)
|
||||
|
||||
|
||||
def build_ws_transport(websocket: WebSocket) -> FastAPIWebsocketTransport:
|
||||
"""裸 WS 输出。序列化用 protobuf(自定义客户端用同款解码);
|
||||
若对接电话商,把 serializer 换成对应的 TwilioFrameSerializer 等即可。
|
||||
"""
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
serializer=ProtobufFrameSerializer(),
|
||||
**_base_params(),
|
||||
),
|
||||
)
|
||||
127
docker-compose.yaml
Normal file
127
docker-compose.yaml
Normal file
@@ -0,0 +1,127 @@
|
||||
# AI Video Assistant 平台 —— docker compose
|
||||
#
|
||||
# 核心服务(默认起):postgres + api + ui
|
||||
# docker compose up -d postgres api # 后端 + 库
|
||||
# docker compose up -d # 再带上前端 ui
|
||||
#
|
||||
# 可选服务(用 profile 推迟到需要时):
|
||||
# docker compose --profile data up -d # + redis / rustfs(后台任务、S3 录音存储)
|
||||
# docker compose --profile remote up -d # + coturn(WebRTC 公网 TURN 穿透)
|
||||
|
||||
services:
|
||||
# ---- 数据库(api 强依赖,默认起) ----
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}"
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [app-network]
|
||||
|
||||
# ---- 后端引擎(pipecat:WebRTC + WS 双输出) ----
|
||||
api:
|
||||
build: ./backend
|
||||
# 调试:挂源码 + --reload,改代码即时生效,无需重建镜像
|
||||
command: uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/.venv # 屏蔽宿主机的 venv(容器用镜像内的依赖)
|
||||
- /app/__pycache__
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
environment:
|
||||
# 容器内连库:用服务名 postgres,覆盖 .env 里的 localhost
|
||||
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
|
||||
CORS_ORIGINS: "http://localhost:3000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
networks: [app-network]
|
||||
|
||||
# ---- 前端(dev 模式,挂源码带 HMR;发布时再做生产构建) ----
|
||||
ui:
|
||||
image: node:22-slim
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: "http://localhost:8000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- api
|
||||
networks: [app-network]
|
||||
|
||||
# ---- 可选(profile: data):后台任务 / 录音存储 ----
|
||||
redis:
|
||||
image: redis:7
|
||||
profiles: ["data"]
|
||||
command: ["--requirepass", "redissecret"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks: [app-network]
|
||||
|
||||
# RustFS:S3 兼容对象存储(MinIO 替代,Rust 实现)
|
||||
# 9000 = S3 API,9001 = Web 控制台。默认账号 rustfsadmin/rustfsadmin。
|
||||
rustfs:
|
||||
image: rustfs/rustfs:latest
|
||||
profiles: ["data"]
|
||||
environment:
|
||||
RUSTFS_VOLUMES: /data # 单盘单节点(开发够用)
|
||||
RUSTFS_ADDRESS: 0.0.0.0:9000 # S3 API
|
||||
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001 # 控制台
|
||||
RUSTFS_CONSOLE_ENABLE: "true"
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*"
|
||||
# 凭证:首次初始化后写入数据卷。改 ak/sk 需先清空 rustfs-data 卷再重起。
|
||||
RUSTFS_ACCESS_KEY: "${S3_ACCESS_KEY:-rustfsadmin}"
|
||||
RUSTFS_SECRET_KEY: "${S3_SECRET_KEY:-rustfsadmin}"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
- "127.0.0.1:9001:9001"
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
networks: [app-network]
|
||||
|
||||
# ---- 可选(profile: remote):WebRTC 公网穿透 ----
|
||||
coturn:
|
||||
image: coturn/coturn:4.8.0
|
||||
profiles: ["remote"]
|
||||
network_mode: host # TURN 需直接占用 UDP 端口段
|
||||
command:
|
||||
- -n
|
||||
- --realm=ai-video
|
||||
- --use-auth-secret
|
||||
- --static-auth-secret=${TURN_SECRET:-changeme}
|
||||
- --min-port=49152
|
||||
- --max-port=49200
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
rustfs-data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
12
frontend/.claude/launch.json
Normal file
12
frontend/.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ai-video-admin",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
frontend/AGENTS.md
Normal file
5
frontend/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
49
frontend/CLAUDE.md
Normal file
49
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # start dev server at localhost:3000
|
||||
npm run build # production build
|
||||
npm run lint # ESLint (no test suite exists yet)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a single-page admin console for managing AI video assistants. It is a Next.js 16 app using the App Router, React 19, Tailwind CSS v4, and shadcn components backed by Radix UI primitives.
|
||||
|
||||
**Navigation model** — the app has no Next.js routes beyond `/`. All "pages" are React components in `src/components/pages/` that are conditionally rendered by `AppShell` based on a `NavKey` state value. `AppShell` owns the active page and sidebar-collapsed state and threads them down as props.
|
||||
|
||||
**Component layers:**
|
||||
- `src/app/` — Next.js entry: `layout.tsx` (fonts, theme-flash script, metadata) and `page.tsx` (renders `<AppShell />`)
|
||||
- `src/components/layout/` — `AppShell` (page-switching shell), `Sidebar` (collapsible nav, 252 → 76px), `Topbar` (theme toggle, notifications), `ThemeToggle`
|
||||
- `src/components/pages/` — one component per nav section; `PlaceholderPage` is a shared editorial header for unimplemented pages
|
||||
- `src/components/ui/` — shadcn primitives (button, card, badge, dialog, etc.)
|
||||
- `src/hooks/` — `use-mobile.ts`
|
||||
- `src/lib/utils.ts` — `cn()` (clsx + tailwind-merge)
|
||||
- `src/data/mock.ts` — static mock data (no API layer yet)
|
||||
|
||||
**Path alias** — `@/` maps to `src/`.
|
||||
|
||||
## Design System
|
||||
|
||||
`DESIGN.md` is the authoritative spec. `src/app/globals.css` is the implementation — it sets the CSS custom properties consumed by all Tailwind utilities. Key rules:
|
||||
|
||||
- **Two themes from one navy palette.** Dark navy is the default (applied via `<html className="dark">` and a no-flash `localStorage` script in `layout.tsx`). Light is cool off-white. Toggle is a `ThemeToggle` component in `Topbar`.
|
||||
- **Never hardcode hex values.** Drive color from semantic tokens: `bg-background`, `text-foreground`, `bg-card`, `text-ink`, `border-hairline`, `bg-surface-strong`, `text-muted-foreground`, etc.
|
||||
- **Display typography** uses Cormorant Garamond (`--font-display`, `font-weight: 300`). Apply via the `.font-display` class or `.display-{mega|xl|lg|md|sm}` size helpers. **Never bold display copy.**
|
||||
- **Body typography** uses Inter (`--font-sans`, `font-weight: 400/500`).
|
||||
- **All CTAs and nav items are pill-shaped** (`rounded-full`). Cards use `rounded-2xl`; hero bands use `rounded-3xl`.
|
||||
- **Gradient orbs** (mint, peach, lavender, sky, rose) are atmosphere-only — they appear as blurred `radial-gradient` backgrounds on hero panels and brand/avatar icons, never as button fills or text colors.
|
||||
|
||||
## Theming Implementation
|
||||
|
||||
`globals.css` defines `:root` (light) and `.dark` variable blocks. All shadcn variables (`--background`, `--card`, `--primary`, `--border`, …) plus editorial extras (`--ink`, `--canvas-soft`, `--surface-strong`, `--hairline*`, `--gradient-*`, `--body-text`) are declared there. The `@theme inline` block wires them into Tailwind utilities.
|
||||
|
||||
## shadcn / Radix
|
||||
|
||||
`components.json` configures shadcn with style `radix-luma`. Add new primitives via `npx shadcn add <component>` — they land in `src/components/ui/`. Icon library is Lucide React.
|
||||
443
frontend/DESIGN.md
Normal file
443
frontend/DESIGN.md
Normal file
@@ -0,0 +1,443 @@
|
||||
---
|
||||
version: alpha
|
||||
name: ai-video-admin-design
|
||||
description: A quietly editorial admin surface for managing AI video assistants, rendered in a navy palette with both a dark and a light theme. The dark canvas is deep navy (`#070b16`) holding off-white ink (`#f1f5ff`); the light canvas is a cool off-white (`#f3f5fb`) holding deep-navy ink (`#0c1426`). Brand voltage is photographic, not chromatic — soft pastel atmospheric gradient orbs (mint → peach → lavender → sky → rose) drift behind hero copy as the only "color" moments. Display runs Cormorant Garamond Light at weight 300 — the editorial signature; Inter carries body, navigation, captions. CTAs are subtle pills: a deep-navy ink pill in light, inverting to an off-white pill in dark. There is no neon accent and no saturated CTA color.
|
||||
|
||||
colors:
|
||||
primary: "#1b2741"
|
||||
primary-active: "#0c1426"
|
||||
ink: "#0c1426"
|
||||
body: "#44516c"
|
||||
body-strong: "#1b2741"
|
||||
muted: "#5d6b86"
|
||||
muted-soft: "#94a0bd"
|
||||
hairline: "#e3e7f1"
|
||||
hairline-soft: "#eef1f8"
|
||||
hairline-strong: "#cbd3e4"
|
||||
canvas: "#f3f5fb"
|
||||
canvas-soft: "#f9fafd"
|
||||
surface-card: "#ffffff"
|
||||
surface-strong: "#e9edf7"
|
||||
on-primary: "#ffffff"
|
||||
gradient-mint: "#a7e5d3"
|
||||
gradient-peach: "#f4c5a8"
|
||||
gradient-lavender: "#c8b8e0"
|
||||
gradient-sky: "#a8c8e8"
|
||||
gradient-rose: "#e8b8c4"
|
||||
semantic-error: "#dc2626"
|
||||
semantic-success: "#16a34a"
|
||||
|
||||
colors-dark:
|
||||
primary: "#e8edf9"
|
||||
primary-foreground: "#0c1426"
|
||||
ink: "#f1f5ff"
|
||||
foreground: "#e8edf9"
|
||||
body: "#a6b2cb"
|
||||
muted: "#93a0bb"
|
||||
muted-soft: "#6c7a96"
|
||||
hairline: "#1b2740"
|
||||
hairline-soft: "#141d30"
|
||||
hairline-strong: "#283450"
|
||||
canvas: "#070b16"
|
||||
canvas-soft: "#0b1322"
|
||||
surface-card: "#0e1626"
|
||||
surface-strong: "#18233a"
|
||||
gradient-mint: "#5fae9b"
|
||||
gradient-peach: "#c08a6b"
|
||||
gradient-lavender: "#8a78ad"
|
||||
gradient-sky: "#5f86b8"
|
||||
gradient-rose: "#b07d8c"
|
||||
semantic-error: "#f87171"
|
||||
semantic-success: "#4ade80"
|
||||
|
||||
typography:
|
||||
display-mega:
|
||||
fontFamily: "'Cormorant Garamond', 'Times New Roman', serif"
|
||||
fontSize: clamp(2.5rem, 5vw, 4rem)
|
||||
fontWeight: 300
|
||||
lineHeight: 1.05
|
||||
letterSpacing: -0.03em
|
||||
display-xl:
|
||||
fontFamily: "'Cormorant Garamond', serif"
|
||||
fontSize: clamp(2rem, 4vw, 3rem)
|
||||
fontWeight: 300
|
||||
lineHeight: 1.08
|
||||
letterSpacing: -0.02em
|
||||
display-lg:
|
||||
fontFamily: "'Cormorant Garamond', serif"
|
||||
fontSize: 36px
|
||||
fontWeight: 300
|
||||
lineHeight: 1.17
|
||||
letterSpacing: -0.01em
|
||||
display-md:
|
||||
fontFamily: "'Cormorant Garamond', serif"
|
||||
fontSize: 32px
|
||||
fontWeight: 300
|
||||
lineHeight: 1.13
|
||||
letterSpacing: -0.01em
|
||||
display-sm:
|
||||
fontFamily: "'Cormorant Garamond', serif"
|
||||
fontSize: 24px
|
||||
fontWeight: 300
|
||||
lineHeight: 1.2
|
||||
letterSpacing: 0
|
||||
title-md:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 20px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.35
|
||||
letterSpacing: 0
|
||||
title-sm:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 18px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.44
|
||||
letterSpacing: 0
|
||||
body-md:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 16px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
letterSpacing: 0.01em
|
||||
body-strong:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 16px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.5
|
||||
letterSpacing: 0.01em
|
||||
body-sm:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 15px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.47
|
||||
letterSpacing: 0.01em
|
||||
caption:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
letterSpacing: 0
|
||||
caption-label:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 12px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.4
|
||||
letterSpacing: 0.08em
|
||||
textTransform: uppercase
|
||||
button:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.0
|
||||
letterSpacing: 0
|
||||
nav-link:
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.4
|
||||
letterSpacing: 0
|
||||
|
||||
rounded:
|
||||
none: 0px
|
||||
sm: "calc(0.75rem * 0.6)" # ~7px
|
||||
md: "calc(0.75rem * 0.8)" # ~10px
|
||||
lg: 0.75rem # 12px (--radius)
|
||||
xl: "calc(0.75rem * 1.4)" # ~17px
|
||||
2xl: "calc(0.75rem * 1.8)" # ~22px
|
||||
3xl: "calc(0.75rem * 2.2)" # ~26px
|
||||
pill: 9999px
|
||||
full: 9999px
|
||||
|
||||
spacing:
|
||||
xxs: 4px
|
||||
xs: 8px
|
||||
sm: 12px
|
||||
base: 16px
|
||||
md: 20px
|
||||
lg: 24px
|
||||
xl: 32px
|
||||
xxl: 48px
|
||||
section: 96px
|
||||
|
||||
components:
|
||||
app-shell:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body-md}"
|
||||
sidebar:
|
||||
backgroundColor: "{colors.surface-card}"
|
||||
textColor: "{colors.body}"
|
||||
borderColor: "{colors.hairline}"
|
||||
width: 252px
|
||||
width-collapsed: 76px
|
||||
topbar:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
borderColor: "{colors.hairline}"
|
||||
height: 81px
|
||||
button-primary:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.button}"
|
||||
rounded: "{rounded.pill}"
|
||||
height: 40px
|
||||
button-outline:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.button}"
|
||||
rounded: "{rounded.pill}"
|
||||
borderColor: "{colors.hairline-strong}"
|
||||
height: 40px
|
||||
nav-item:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.muted}"
|
||||
typography: "{typography.nav-link}"
|
||||
rounded: "{rounded.pill}"
|
||||
height: 44px
|
||||
nav-item-active:
|
||||
backgroundColor: "{colors.surface-strong}"
|
||||
textColor: "{colors.ink}"
|
||||
hero-band:
|
||||
backgroundColor: "{colors.canvas-soft}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.display-xl}"
|
||||
rounded: "{rounded.3xl}"
|
||||
borderColor: "{colors.hairline}"
|
||||
padding: 64px
|
||||
feature-card:
|
||||
backgroundColor: "{colors.surface-card}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.title-sm}"
|
||||
rounded: "{rounded.2xl}"
|
||||
borderColor: "{colors.hairline}"
|
||||
padding: 24px
|
||||
section-card:
|
||||
backgroundColor: "{colors.surface-card}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.2xl}"
|
||||
borderColor: "{colors.hairline}"
|
||||
padding: 24px
|
||||
icon-plate:
|
||||
backgroundColor: "{colors.surface-strong}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.full}"
|
||||
size: 40px
|
||||
text-input:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.md}"
|
||||
borderColor: "{colors.hairline-strong}"
|
||||
badge-pill:
|
||||
backgroundColor: "{colors.surface-strong}"
|
||||
textColor: "{colors.muted}"
|
||||
typography: "{typography.caption-label}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 4px 12px
|
||||
brand-icon:
|
||||
backgroundColor: "{colors.primary} + sky/lavender gradient orb overlay"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.xl}"
|
||||
size: 44px
|
||||
avatar:
|
||||
backgroundColor: "{colors.primary} + sky gradient orb overlay"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.full}"
|
||||
size: 32px
|
||||
gradient-orb:
|
||||
background: "radial-gradient with one of {colors.gradient-*}"
|
||||
blur: 48-64px
|
||||
opacity: 0.45-0.6
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
AI 视频助手管理台 reads like a quietly editorial print magazine that happens to be an admin console. It ships in **two themes built from one navy palette**:
|
||||
|
||||
- **Dark navy** (default): deep-navy canvas `{colors-dark.canvas}` (#070b16) holding off-white ink `{colors-dark.ink}` (#f1f5ff).
|
||||
- **Light navy**: cool off-white canvas `{colors.canvas}` (#f3f5fb) holding deep-navy ink `{colors.ink}` (#0c1426).
|
||||
|
||||
The brand voltage is **photographic, not chromatic**: soft pastel atmospheric gradient orbs (mint, peach, lavender, sky, rose) drift behind hero/feature panels as the only "color" moments. There is no neon accent, no saturated CTA color, and no cyan/blue dev-console gradient anymore.
|
||||
|
||||
Type pairs **Cormorant Garamond Light** (display serif at weight 300 — the open-source substitute for Waldenburg) with **Inter** for body, navigation, captions, and buttons. The display weight at 300 is the editorial signature — never bold. Latin display runs in the serif; CJK display falls back to the system serif/sans at weight 300 (still light/editorial).
|
||||
|
||||
CTAs are subtle pills: in light mode the primary is a deep-navy ink pill (`{component.button-primary}`); in dark mode it inverts to an off-white pill (`{colors-dark.primary}` → off-white, text `{colors-dark.primary-foreground}`). The secondary is a transparent hairline-outline pill.
|
||||
|
||||
**Key Characteristics:**
|
||||
- One navy palette, two themes; dark is the default, persisted to `localStorage` with a no-flash bootstrap script in `layout.tsx`.
|
||||
- Off-white / deep-navy ink. No saturated CTA color — ink pill only.
|
||||
- Display runs Cormorant Garamond Light at weight 300 — editorial voice.
|
||||
- Body runs Inter at 400/500 with subtle +0.01em tracking.
|
||||
- Pastel gradient orbs (5 tokens) used as atmospheric decoration only.
|
||||
- Pill geometry (`{rounded.pill}`) for every CTA, nav item, and badge; `{rounded.2xl}` for cards, `{rounded.3xl}` for hero bands.
|
||||
- Hairline borders + a single soft shadow tier — no layered-surface dev-console stack.
|
||||
- 96px section rhythm; ~40px page padding; 1180px max content width.
|
||||
|
||||
## Colors
|
||||
|
||||
The two `colors` / `colors-dark` blocks above are the source of truth and are wired into `globals.css` as the shadcn variables (`--background`, `--foreground`, `--card`, `--primary`, `--border`, `--muted-foreground`, …) plus editorial extras (`--ink`, `--body-text`, `--surface-strong`, `--hairline*`, `--canvas-soft`, `--gradient-*`). Tailwind utilities like `bg-background`, `text-ink`, `border-hairline`, `bg-surface-strong`, and `text-muted-foreground` resolve to the active theme automatically.
|
||||
|
||||
### Primary (CTA)
|
||||
- **Light** `{colors.primary}` (#1b2741): deep-navy ink pill, white text. Press → `{colors.primary-active}` (#0c1426).
|
||||
- **Dark** `{colors-dark.primary}` (#e8edf9): off-white pill, deep-navy text (`#0c1426`). The editorial inversion — used scarcely.
|
||||
|
||||
### Surfaces
|
||||
- **Canvas** — page floor. Light #f3f5fb / Dark #070b16.
|
||||
- **Canvas Soft** — hero/placeholder bands. Light #f9fafd / Dark #0b1322.
|
||||
- **Surface Card** — content cards, popovers. Light #ffffff / Dark #0e1626.
|
||||
- **Surface Strong** — icon plates, badges, active nav. Light #e9edf7 / Dark #18233a.
|
||||
|
||||
### Hairlines
|
||||
- **Hairline** — default 1px divider/card outline. Light #e3e7f1 / Dark #1b2740.
|
||||
- **Hairline Soft** — lighter divider. Light #eef1f8 / Dark #141d30.
|
||||
- **Hairline Strong** — input/outline-button border. Light #cbd3e4 / Dark #283450.
|
||||
|
||||
### Text
|
||||
- **Ink** — display & primary text. Light #0c1426 / Dark #f1f5ff.
|
||||
- **Body** — running copy. Light #44516c / Dark #a6b2cb.
|
||||
- **Muted** — descriptions, sub-labels. Light #5d6b86 / Dark #93a0bb.
|
||||
- **Muted Soft** — captions, placeholders. Light #94a0bd / Dark #6c7a96.
|
||||
|
||||
### Atmospheric Gradient Orbs (signature)
|
||||
Five pastel stops — `gradient-mint`, `gradient-peach`, `gradient-lavender`, `gradient-sky`, `gradient-rose`. In dark mode they shift to lower-chroma navy-compatible variants (e.g. sky #a8c8e8 → #5f86b8). They appear ONLY as soft, blurred radial blooms behind hero/feature/placeholder copy and as the orb overlay on the brand icon/avatar. Never as button fills, text colors, or flat card surfaces.
|
||||
|
||||
### Semantic
|
||||
- **Success** — Light #16a34a / Dark #4ade80.
|
||||
- **Error** — Light #dc2626 / Dark #f87171.
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
**Cormorant Garamond** is the display serif (`--font-display`, weight 300) — the open-source substitute for the licensed Waldenburg Light. **Inter** carries body, navigation, captions, and buttons (`--font-sans`). **Geist Mono** is loaded for monospace. Helper classes in `globals.css`: `.font-display`, `.display-mega/-xl/-lg/-md/-sm`, `.caption-label`.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Token | Size | Weight | Tracking | Use |
|
||||
|---|---|---|---|---|
|
||||
| `{typography.display-mega}` | clamp 40–64px | 300 | -0.03em | Marketing-scale hero |
|
||||
| `{typography.display-xl}` | clamp 32–48px | 300 | -0.02em | Homepage hero h1 |
|
||||
| `{typography.display-lg}` | 36px | 300 | -0.01em | Page titles |
|
||||
| `{typography.display-md}` | 32px | 300 | -0.01em | Section heads |
|
||||
| `{typography.display-sm}` | 24px | 300 | 0 | Card group titles |
|
||||
| `{typography.title-md}` | 20px | 500 | 0 | Component titles (Inter) |
|
||||
| `{typography.title-sm}` | 18px | 500 | 0 | List labels |
|
||||
| `{typography.body-md}` | 16px | 400 | +0.01em | Default body |
|
||||
| `{typography.body-strong}` | 16px | 500 | +0.01em | Emphasized body |
|
||||
| `{typography.body-sm}` | 15px | 400 | +0.01em | Compact body |
|
||||
| `{typography.caption-label}` | 12px | 600 | +0.08em, UPPER | Section labels, badges |
|
||||
| `{typography.button}` | 14px | 500 | 0 | CTA pill |
|
||||
| `{typography.nav-link}` | 14px | 400 | 0 | Sidebar nav |
|
||||
|
||||
### Principles
|
||||
- **Display weight stays at 300.** Cormorant Garamond Light is the editorial signature. Never bold display copy.
|
||||
- **Subtle tracking on body.** Inter at +0.01em (applied globally on `body`).
|
||||
- **Negative tracking on display.** Tighter as size grows (-0.01em → -0.03em).
|
||||
- **Caption labels** are the editorial section marker — uppercase, 600, +0.08em.
|
||||
|
||||
## Layout
|
||||
|
||||
### Spacing & Container
|
||||
- Base unit 4px; section rhythm 96px (`{spacing.section}`).
|
||||
- Page padding: `px-8 py-10`. Max content width 1180px.
|
||||
- Sidebar: 252px expanded, 76px collapsed.
|
||||
|
||||
### Whitespace Philosophy
|
||||
Generous editorial pacing. Hero bands get large internal padding (64px) with a gradient orb in a corner; content cards sit 16–24px apart.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
The system uses **hairline + soft drop**. Cards float above the canvas via a 1px hairline and a single subtle shadow tier (`shadow-sm`, deepening to `shadow-md` on hover). Atmospheric depth comes from the gradient orbs, not from layered surface steps.
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| Canvas | `{colors.canvas}` | App background, topbar |
|
||||
| Card | `{colors.surface-card}` | Content cards, popovers |
|
||||
| Hairline border | 1px `{colors.hairline}` | Card/input outlines |
|
||||
| Soft drop | `shadow-sm` → `shadow-md` on hover | Card elevation |
|
||||
| Gradient orb | blurred radial bloom, opacity 0.45–0.6 | Atmospheric depth only |
|
||||
|
||||
## Shapes
|
||||
|
||||
### Border Radius Scale (`--radius: 0.75rem` = 12px)
|
||||
|
||||
| Token | Approx | Use |
|
||||
|---|---|---|
|
||||
| `{rounded.md}` | ~10px | Form inputs |
|
||||
| `{rounded.lg}` | 12px | Compact elements |
|
||||
| `{rounded.xl}` | ~17px | Brand icon |
|
||||
| `{rounded.2xl}` | ~22px | Feature/section cards |
|
||||
| `{rounded.3xl}` | ~26px | Hero/placeholder bands |
|
||||
| `{rounded.pill}` | 9999px | All buttons, nav items, badges |
|
||||
| `{rounded.full}` | 9999px | Avatars, icon plates |
|
||||
|
||||
## Components
|
||||
|
||||
### App Shell
|
||||
**`app-shell`** — `bg-background text-foreground`. Flex row: sidebar left, main column right (topbar + scrollable content at `px-8 py-10`).
|
||||
|
||||
### Sidebar
|
||||
**`sidebar`** — `bg-sidebar`, border-right `{colors.hairline}`, width 252/76px. Brand block: gradient-orb brand icon + serif wordmark + uppercase `管理台` caption-label. Nav items are pill-shaped (44px); active uses `bg-sidebar-accent` with foreground text.
|
||||
|
||||
### Top Bar
|
||||
**`topbar`** — `bg-background`, border-bottom hairline, height 81px. Right-aligned: Help outline pill, **theme toggle** (sun/moon), notification bell, user avatar pill — all hairline-outline style.
|
||||
|
||||
### Buttons (pills)
|
||||
**`button-primary`** — ink pill (light) / off-white pill (dark), `{rounded.pill}`, height 40px (`size="lg"`).
|
||||
**`button-outline`** — transparent pill, 1px `{colors.hairline-strong}` border, text ink; hover `bg-surface-strong`.
|
||||
|
||||
### Hero & Atmospheric
|
||||
**`hero-band`** — `bg-canvas-soft`, `{rounded.3xl}`, hairline border, 64px padding, with 1–2 blurred gradient orbs (sky + lavender) behind editorial display copy, a caption-label eyebrow, and two CTAs.
|
||||
**`gradient-orb`** — blurred radial bloom in one of the five pastel tokens; pure atmosphere, holds no content.
|
||||
|
||||
### Cards
|
||||
**`feature-card` / `section-card`** — `bg-card`, `{rounded.2xl}`, hairline border, `shadow-sm`. Icon plate is a 40px `{colors.surface-strong}` circle with neutral foreground icon (no colored accent).
|
||||
|
||||
### Forms & Tags
|
||||
**`text-input`** — `bg-background`, text ink, 1px `{colors.hairline-strong}` border, `{rounded.md}`; focus thickens border to ring.
|
||||
**`badge-pill`** — `bg-surface-strong`, caption-label text, `{rounded.pill}`.
|
||||
|
||||
### Brand Elements
|
||||
**`brand-icon`** / **`avatar`** — `{colors.primary}` base with a sky/lavender gradient-orb overlay, `{rounded.xl}` (icon) / `{rounded.full}` (avatar). The pastel orb replaces the old cyan→blue gradient.
|
||||
|
||||
## Theming
|
||||
|
||||
- Two themes from one palette: `.dark` toggles the variable set in `globals.css`. Dark is the default `<html className="dark">`.
|
||||
- A `ThemeToggle` (sun/moon) in the topbar flips `documentElement.classList` and writes `localStorage.theme`.
|
||||
- A no-flash inline script in `layout.tsx` applies the stored theme before paint (defaults to dark).
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Reserve `{colors.primary}` (ink/off-white pill) for primary CTAs.
|
||||
- Use Cormorant Garamond Light at weight 300 for every display headline. Never bold.
|
||||
- Use Inter at +0.01em tracking for body.
|
||||
- Use atmospheric gradient orbs (mint/peach/lavender/sky/rose) as decoration only.
|
||||
- Use the pill shape for every CTA, nav item, and badge.
|
||||
- Drive color from semantic tokens (`bg-card`, `text-ink`, `border-hairline`) so both themes work — never inline hex.
|
||||
|
||||
### Don't
|
||||
- Don't reintroduce the cyan/blue dev-console accent or layered-navy surface stack.
|
||||
- Don't bold display copy — it sits at weight 300.
|
||||
- Don't use gradient orbs as button fills, text colors, or flat card backgrounds.
|
||||
- Don't use sharp 0px or `{rounded.xl}` corners on CTAs — pill geometry is the brand button.
|
||||
- Don't drop body Inter to weight 300 to match the serif — body stays at 400/500.
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
| Name | Width | Key Changes |
|
||||
|---|---|---|
|
||||
| Mobile | < 640px | Feature grid 1-up; sidebar collapsed; user name hidden; display sizes shrink via clamp |
|
||||
| Tablet | 640–1024px | Feature grid 2-up |
|
||||
| Desktop | 1024–1280px | Feature grid 3-up; full sidebar |
|
||||
| Wide | > 1280px | Content caps at 1180px |
|
||||
|
||||
### Touch Targets
|
||||
- Nav items 44px; primary buttons 40px (`size="lg"`).
|
||||
- Sidebar collapses to 76px icon-only via toggle.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- Cormorant Garamond covers Latin only; CJK display headings fall back to the system serif/sans at weight 300. A bundled light CJK serif (e.g. a Noto Serif SC weight) is not yet wired in.
|
||||
- Several inner pages (Components, History, Test, Workflow, Profile) currently use the shared `PlaceholderPage` editorial header and await real content.
|
||||
- Animation timings (orb drift, hero entrance, theme cross-fade) out of scope.
|
||||
- Form validation states beyond focus not yet documented.
|
||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-luma",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
8
frontend/next.config.ts
Normal file
8
frontend/next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: ["127.0.0.1"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
11651
frontend/package-lock.json
generated
Normal file
11651
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "ai-video-admin-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.7",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.10.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 385 B |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
234
frontend/src/app/globals.css
Normal file
234
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,234 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-display: var(--font-display);
|
||||
--font-heading: var(--font-display);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
|
||||
/* Editorial (ElevenLabs-derived) tokens — navy palette */
|
||||
--color-canvas-soft: var(--canvas-soft);
|
||||
--color-surface-strong: var(--surface-strong);
|
||||
--color-ink: var(--ink);
|
||||
--color-body: var(--body-text);
|
||||
--color-muted-soft: var(--muted-soft);
|
||||
--color-hairline: var(--hairline);
|
||||
--color-hairline-soft: var(--hairline-soft);
|
||||
--color-hairline-strong: var(--hairline-strong);
|
||||
--color-on-primary: var(--on-primary);
|
||||
--color-success: var(--success);
|
||||
--color-gradient-mint: var(--gradient-mint);
|
||||
--color-gradient-peach: var(--gradient-peach);
|
||||
--color-gradient-lavender: var(--gradient-lavender);
|
||||
--color-gradient-sky: var(--gradient-sky);
|
||||
--color-gradient-rose: var(--gradient-rose);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
/* ---------- Light navy (editorial off-white, navy ink) ---------- */
|
||||
:root {
|
||||
--background: #f3f5fb;
|
||||
--foreground: #0f1b33;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f1b33;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0f1b33;
|
||||
--primary: #1b2741;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e9edf7;
|
||||
--secondary-foreground: #1b2741;
|
||||
--muted: #eef1f8;
|
||||
--muted-foreground: #5d6b86;
|
||||
--accent: #e9edf7;
|
||||
--accent-foreground: #1b2741;
|
||||
--destructive: #dc2626;
|
||||
--border: #dfe4f0;
|
||||
--input: #cbd3e4;
|
||||
--ring: #94a0bd;
|
||||
--chart-1: #1b2741;
|
||||
--chart-2: #3a4a6b;
|
||||
--chart-3: #5d6b86;
|
||||
--chart-4: #94a0bd;
|
||||
--chart-5: #c8d0e2;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Editorial tokens */
|
||||
--canvas-soft: #f9fafd;
|
||||
--surface-strong: #e9edf7;
|
||||
--ink: #0c1426;
|
||||
--body-text: #44516c;
|
||||
--muted-soft: #94a0bd;
|
||||
--hairline: #e3e7f1;
|
||||
--hairline-soft: #eef1f8;
|
||||
--hairline-strong: #cbd3e4;
|
||||
--on-primary: #ffffff;
|
||||
--success: #16a34a;
|
||||
|
||||
/* Atmospheric pastel orbs (cool-leaning for navy) */
|
||||
--gradient-mint: #a7e5d3;
|
||||
--gradient-peach: #f4c5a8;
|
||||
--gradient-lavender: #c8b8e0;
|
||||
--gradient-sky: #a8c8e8;
|
||||
--gradient-rose: #e8b8c4;
|
||||
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #0f1b33;
|
||||
--sidebar-primary: #1b2741;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #eef1f8;
|
||||
--sidebar-accent-foreground: #1b2741;
|
||||
--sidebar-border: #e3e7f1;
|
||||
--sidebar-ring: #94a0bd;
|
||||
}
|
||||
|
||||
/* ---------- Dark navy (deep navy canvas, off-white ink) ---------- */
|
||||
.dark {
|
||||
--background: #070b16;
|
||||
--foreground: #e8edf9;
|
||||
--card: #0e1626;
|
||||
--card-foreground: #e8edf9;
|
||||
--popover: #0e1626;
|
||||
--popover-foreground: #e8edf9;
|
||||
--primary: #e8edf9;
|
||||
--primary-foreground: #0c1426;
|
||||
--secondary: #18233a;
|
||||
--secondary-foreground: #e8edf9;
|
||||
--muted: #141d30;
|
||||
--muted-foreground: #93a0bb;
|
||||
--accent: #18233a;
|
||||
--accent-foreground: #e8edf9;
|
||||
--destructive: #f87171;
|
||||
--border: #1b2740;
|
||||
--input: #283450;
|
||||
--ring: #4a5876;
|
||||
--chart-1: #e8edf9;
|
||||
--chart-2: #c8d0e2;
|
||||
--chart-3: #93a0bb;
|
||||
--chart-4: #5d6b86;
|
||||
--chart-5: #2a3654;
|
||||
|
||||
/* Editorial tokens */
|
||||
--canvas-soft: #0b1322;
|
||||
--surface-strong: #18233a;
|
||||
--ink: #f1f5ff;
|
||||
--body-text: #a6b2cb;
|
||||
--muted-soft: #6c7a96;
|
||||
--hairline: #1b2740;
|
||||
--hairline-soft: #141d30;
|
||||
--hairline-strong: #283450;
|
||||
--on-primary: #0c1426;
|
||||
--success: #4ade80;
|
||||
|
||||
--gradient-mint: #5fae9b;
|
||||
--gradient-peach: #c08a6b;
|
||||
--gradient-lavender: #8a78ad;
|
||||
--gradient-sky: #5f86b8;
|
||||
--gradient-rose: #b07d8c;
|
||||
|
||||
--sidebar: #0a111e;
|
||||
--sidebar-foreground: #e8edf9;
|
||||
--sidebar-primary: #e8edf9;
|
||||
--sidebar-primary-foreground: #0c1426;
|
||||
--sidebar-accent: #18233a;
|
||||
--sidebar-accent-foreground: #e8edf9;
|
||||
--sidebar-border: #1b2740;
|
||||
--sidebar-ring: #4a5876;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Waldenburg Light substitute — EB Garamond at weight 300. The editorial
|
||||
display signature: serif, light, tightly tracked. Never bold. */
|
||||
.font-display {
|
||||
font-family: var(--font-display), "Times New Roman", serif;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.display-mega {
|
||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.display-xl {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.display-lg {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.17;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.display-md {
|
||||
font-size: 2rem;
|
||||
line-height: 1.13;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.display-sm {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Caption label — uppercase, tracked, the editorial section marker */
|
||||
.caption-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
54
frontend/src/app/layout.tsx
Normal file
54
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist_Mono, Inter, Cormorant_Garamond } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
// Waldenburg Light is licensed; Cormorant Garamond at weight 300 is a close
|
||||
// open-source substitute for the light editorial display voice.
|
||||
const display = Cormorant_Garamond({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500"],
|
||||
variable: "--font-display",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI 视频助手 · 管理台",
|
||||
description: "创建、配置、测试并发布 AI 视频助手",
|
||||
};
|
||||
|
||||
// Apply the persisted theme before paint to avoid a flash. Defaults to dark navy.
|
||||
const themeScript = `(function(){try{var t=localStorage.getItem('theme');if(t==='light'){document.documentElement.classList.remove('dark')}else{document.documentElement.classList.add('dark')}}catch(e){document.documentElement.classList.add('dark')}})();`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={cn(
|
||||
"h-full",
|
||||
"antialiased",
|
||||
"dark",
|
||||
geistMono.variable,
|
||||
"font-sans",
|
||||
inter.variable,
|
||||
display.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
export default function Home() {
|
||||
return <AppShell />;
|
||||
}
|
||||
62
frontend/src/components/layout/AppShell.tsx
Normal file
62
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
import { HomePage } from "@/components/pages/HomePage";
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
import { ComponentsModelsPage } from "@/components/pages/ComponentsModelsPage";
|
||||
import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage";
|
||||
import { ComponentsToolsPage } from "@/components/pages/ComponentsToolsPage";
|
||||
import { HistoryPage } from "@/components/pages/HistoryPage";
|
||||
import { DashboardPage } from "@/components/pages/DashboardPage";
|
||||
import { TestPage } from "@/components/pages/TestPage";
|
||||
import { WorkflowPage } from "@/components/pages/WorkflowPage";
|
||||
import { ProfilePage } from "@/components/pages/ProfilePage";
|
||||
|
||||
export type NavKey =
|
||||
| "home"
|
||||
| "assistants"
|
||||
| "components-models"
|
||||
| "components-knowledge"
|
||||
| "components-tools"
|
||||
| "history"
|
||||
| "dashboard"
|
||||
| "test"
|
||||
| "workflow"
|
||||
| "profile";
|
||||
|
||||
|
||||
export function AppShell() {
|
||||
const [active, setActive] = useState<NavKey>("home");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar
|
||||
active={active}
|
||||
collapsed={collapsed}
|
||||
onNavigate={setActive}
|
||||
onToggle={() => setCollapsed((v) => !v)}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Topbar />
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-8 py-10">
|
||||
{active === "home" && <HomePage onNavigate={setActive} />}
|
||||
{active === "assistants" && <AssistantPage />}
|
||||
{active === "components-models" && <ComponentsModelsPage />}
|
||||
{active === "components-knowledge" && <ComponentsKnowledgePage />}
|
||||
{active === "components-tools" && <ComponentsToolsPage />}
|
||||
{active === "history" && <HistoryPage />}
|
||||
{active === "dashboard" && <DashboardPage />}
|
||||
{active === "test" && <TestPage />}
|
||||
{active === "workflow" && <WorkflowPage />}
|
||||
{active === "profile" && <ProfilePage />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
frontend/src/components/layout/Sidebar.tsx
Normal file
329
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Database,
|
||||
Wrench,
|
||||
Home,
|
||||
PlayCircle,
|
||||
Video,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import type { NavKey } from "./AppShell";
|
||||
|
||||
type SidebarProps = {
|
||||
active: NavKey;
|
||||
collapsed: boolean;
|
||||
onNavigate: (key: NavKey) => void;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const mainItems: Array<{
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
}> = [
|
||||
{ key: "home", label: "首页", icon: Home },
|
||||
{ key: "test", label: "测试助手", icon: PlayCircle },
|
||||
];
|
||||
|
||||
const componentSubItems: Array<{
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
}> = [
|
||||
{ key: "components-models", label: "模型资源", icon: Brain },
|
||||
{ key: "components-knowledge", label: "知识库", icon: Database },
|
||||
{ key: "components-tools", label: "工具资源", icon: Wrench },
|
||||
];
|
||||
|
||||
const monitorSubItems: Array<{
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
}> = [
|
||||
{ key: "history", label: "历史记录", icon: Clock3 },
|
||||
{ key: "dashboard", label: "数据看板", icon: Database },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
active,
|
||||
collapsed,
|
||||
onNavigate,
|
||||
onToggle,
|
||||
}: SidebarProps) {
|
||||
const assistantActive = active === "assistants";
|
||||
|
||||
const componentActive =
|
||||
active === "components-models" || active === "components-knowledge" || active === "components-tools";
|
||||
|
||||
const monitorActive = monitorSubItems.some((item) => item.key === active);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
"flex shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar transition-[width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width]",
|
||||
collapsed ? "w-[76px]" : "w-[252px]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="shrink-0 flex h-16 items-center gap-3 border-b border-sidebar-border px-5 transition-[padding] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]">
|
||||
<div
|
||||
className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl text-on-primary shadow-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--primary)",
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--gradient-sky) 70%, transparent), transparent 60%), radial-gradient(circle at 80% 90%, color-mix(in srgb, var(--gradient-lavender) 65%, transparent), transparent 55%)",
|
||||
}}
|
||||
>
|
||||
<Video size={22} style={{ color: "var(--primary-foreground)" }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"min-w-0 overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "w-0 opacity-0 -translate-x-2" : "w-[140px] opacity-100 translate-x-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="truncate font-display text-base text-foreground">
|
||||
AI 视频助手
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2 [scrollbar-width:thin] [scrollbar-color:var(--hairline-strong)_transparent]">
|
||||
<div className="space-y-1">
|
||||
<NavButton
|
||||
active={active === "home"}
|
||||
collapsed={collapsed}
|
||||
icon={Home}
|
||||
label="首页"
|
||||
onClick={() => onNavigate("home")}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<NavButton
|
||||
active={assistantActive}
|
||||
collapsed={collapsed}
|
||||
icon={Bot}
|
||||
label="创建助手"
|
||||
onClick={() => onNavigate("assistants")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
{collapsed ? (
|
||||
<div
|
||||
className="flex h-8 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
title="组件库"
|
||||
>
|
||||
<span className="h-px w-6 rounded-full bg-hairline-strong" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={[
|
||||
"flex h-11 w-full items-center gap-3 rounded-full px-3 text-sm",
|
||||
componentActive ? "text-foreground" : "text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<Boxes size={18} />
|
||||
<span className="font-medium">组件库</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={[
|
||||
"mt-1 space-y-1 transition-[padding] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "pl-0" : "pl-5",
|
||||
].join(" ")}
|
||||
>
|
||||
{componentSubItems.map((item) => (
|
||||
<NavButton
|
||||
key={item.key}
|
||||
active={active === item.key}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
small
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="pt-2">
|
||||
{collapsed ? (
|
||||
<div
|
||||
className="flex h-8 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
title="监控观察"
|
||||
>
|
||||
<span className="h-px w-6 rounded-full bg-hairline-strong" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={[
|
||||
"flex h-11 w-full items-center gap-3 rounded-full px-3 text-sm",
|
||||
monitorActive ? "text-foreground" : "text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<Boxes size={18} />
|
||||
<span className="font-medium">监控观察</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={[
|
||||
"mt-1 space-y-1 transition-[padding] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "pl-0" : "pl-5",
|
||||
].join(" ")}
|
||||
>
|
||||
{monitorSubItems.map((item) => (
|
||||
<NavButton
|
||||
key={item.key}
|
||||
active={active === item.key}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
{mainItems.slice(1).map((item) => (
|
||||
<NavButton
|
||||
key={item.key}
|
||||
active={active === item.key}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 space-y-2 border-t border-sidebar-border bg-sidebar p-3 shadow-[0_-12px_24px_rgba(0,0,0,0.12)]">
|
||||
{/* 个人中心 */}
|
||||
<button
|
||||
onClick={() => onNavigate("profile")}
|
||||
title={collapsed ? "个人中心 · 管理员" : undefined}
|
||||
className={[
|
||||
"group relative flex w-full items-center overflow-hidden rounded-2xl border py-2 text-left transition-[background-color,color,border-color,box-shadow,transform] duration-200 active:scale-[0.98]",
|
||||
active === "profile"
|
||||
? "border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "border-transparent text-muted-foreground hover:border-sidebar-border hover:bg-sidebar-accent/60 hover:text-foreground hover:shadow-sm",
|
||||
collapsed ? "justify-center gap-0 px-0" : "gap-3 px-2.5",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<span
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium text-on-primary shadow-sm transition-transform duration-200 group-hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: "var(--primary)",
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--gradient-sky) 70%, transparent), transparent 60%), radial-gradient(circle at 80% 90%, color-mix(in srgb, var(--gradient-lavender) 65%, transparent), transparent 55%)",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--primary-foreground)" }}>管</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-sidebar"
|
||||
style={{ backgroundColor: "var(--success)" }}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"min-w-0 overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "w-0 flex-none opacity-0 -translate-x-2" : "flex-1 opacity-100 translate-x-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
管理员
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 收起 / 展开侧栏 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={collapsed ? "展开侧栏" : "收起侧栏"}
|
||||
className={[
|
||||
"group flex h-10 w-full items-center overflow-hidden rounded-full border border-hairline-strong text-sm text-muted-foreground transition-[background-color,color,border-color,transform] duration-200 hover:bg-surface-strong hover:text-foreground active:scale-[0.98]",
|
||||
collapsed ? "justify-center gap-0 px-0" : "justify-between gap-2 px-3.5",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"min-w-0 truncate transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "w-0 opacity-0 -translate-x-2" : "opacity-100 translate-x-0",
|
||||
].join(" ")}
|
||||
>
|
||||
收起侧栏
|
||||
</span>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
className={[
|
||||
"shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-110",
|
||||
collapsed ? "rotate-180" : "rotate-0",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
active,
|
||||
collapsed,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
small = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={collapsed ? label : undefined}
|
||||
className={[
|
||||
"group mt-1 flex w-full items-center overflow-hidden rounded-full text-sm transition-[background-color,color,transform] duration-200 active:scale-[0.98]",
|
||||
small ? "h-10" : "h-11",
|
||||
active
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
collapsed ? "justify-center gap-0 px-0" : "gap-3 px-3 hover:translate-x-0.5",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon
|
||||
size={small ? 16 : 18}
|
||||
className="shrink-0 transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
<span
|
||||
className={[
|
||||
"min-w-0 truncate transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "w-0 opacity-0 -translate-x-2" : "w-[150px] opacity-100 translate-x-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/layout/ThemeToggle.tsx
Normal file
34
frontend/src/components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains("dark"));
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
try {
|
||||
localStorage.setItem("theme", next ? "dark" : "light");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={dark ? "切换到浅色" : "切换到深色"}
|
||||
aria-label="切换主题"
|
||||
className="flex size-10 items-center justify-center rounded-full border border-hairline-strong text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
{dark ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/layout/Topbar.tsx
Normal file
41
frontend/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Bell, HelpCircle, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export function Topbar() {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center justify-end gap-2 border-b border-border bg-background px-8">
|
||||
<TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-hairline-strong text-muted-foreground hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<HelpCircle />
|
||||
帮助
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
className="border-hairline-strong text-muted-foreground hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<Bell />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">通知</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
1701
frontend/src/components/pages/AssistantPage.tsx
Normal file
1701
frontend/src/components/pages/AssistantPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
137
frontend/src/components/pages/AssistantWorkflowPage.tsx
Normal file
137
frontend/src/components/pages/AssistantWorkflowPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ArrowRight, Boxes, GitBranch, Plus, Workflow } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function AssistantWorkflowPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="font-display display-lg text-ink">创建助手</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
||||
通过节点编排方式创建复杂 AI
|
||||
视频助手流程,适合多轮任务、工具调用、知识库检索和人工协同场景。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-20 -top-24 h-72 w-72 rounded-full opacity-55 blur-3xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-mint) 55%, transparent), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-start gap-5">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-strong text-foreground">
|
||||
<Workflow size={30} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="caption-label inline-flex rounded-full bg-surface-strong px-3 py-1 text-muted-foreground">
|
||||
即将开放
|
||||
</div>
|
||||
|
||||
<h2 className="font-display display-sm mt-5 text-ink">
|
||||
工作流画布正在设计中
|
||||
</h2>
|
||||
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-body">
|
||||
后续这里会提供可视化节点画布,你可以拖拽配置开始节点、意图识别节点、知识库检索节点、
|
||||
大模型回答节点、工具调用节点、人工接管节点和结束节点。
|
||||
</p>
|
||||
|
||||
<div className="mt-7 flex gap-3">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Plus size={16} />
|
||||
新建工作流
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||
>
|
||||
查看模板
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-3 gap-4">
|
||||
<FeatureCard
|
||||
icon={<GitBranch size={20} />}
|
||||
title="节点编排"
|
||||
description="通过拖拽节点组织多轮对话、判断分支和任务流转。"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Boxes size={20} />}
|
||||
title="组件复用"
|
||||
description="复用模型、知识库、语音识别、声音资源和工具插件。"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Workflow size={20} />}
|
||||
title="流程调试"
|
||||
description="支持逐节点测试、查看输入输出和定位失败原因。"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="font-display display-sm text-ink">未来画布结构预览</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
当前是静态占位,后续可替换为 React Flow 或自研画布组件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 overflow-x-auto rounded-2xl border border-hairline bg-canvas-soft p-5">
|
||||
{["开始", "意图识别", "知识库检索", "模型回答", "工具调用", "结束"].map(
|
||||
(item, index) => (
|
||||
<div key={item} className="flex items-center gap-3">
|
||||
<div className="min-w-[128px] rounded-xl border border-hairline bg-card p-4 text-center shadow-sm">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{item}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-soft">
|
||||
Node {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < 5 && (
|
||||
<ArrowRight size={18} className="shrink-0 text-muted-soft" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-card p-6 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="font-medium text-foreground">{title}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/ComponentsKnowledgePage.tsx
Normal file
10
frontend/src/components/pages/ComponentsKnowledgePage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function ComponentsKnowledgePage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="知识库"
|
||||
description="统一管理大语言模型、语音识别、声音资源、知识库与工具插件。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
794
frontend/src/components/pages/ComponentsModelsPage.tsx
Normal file
794
frontend/src/components/pages/ComponentsModelsPage.tsx
Normal file
@@ -0,0 +1,794 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plug,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
|
||||
|
||||
type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini";
|
||||
|
||||
/** 各资源类型可选的接口类型 */
|
||||
const interfaceOptionsByType: Record<ModelType, InterfaceType[]> = {
|
||||
LLM: ["openai"],
|
||||
ASR: ["openai", "xfyun", "dashscope"],
|
||||
TTS: ["openai", "xfyun", "dashscope"],
|
||||
Realtime: ["openai", "gemini"],
|
||||
Embedding: ["openai"],
|
||||
};
|
||||
|
||||
const modelTypes: ModelType[] = ["LLM", "ASR", "TTS", "Realtime", "Embedding"];
|
||||
|
||||
type ModelResource = {
|
||||
id: string;
|
||||
name: string;
|
||||
modelId: string;
|
||||
type: ModelType;
|
||||
interfaceType: InterfaceType;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
const mockModels: ModelResource[] = [
|
||||
{
|
||||
id: "model_001",
|
||||
name: "DeepSeek-V3",
|
||||
modelId: "deepseek-chat",
|
||||
type: "LLM",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://api.deepseek.com/v1",
|
||||
apiKey: "sk-deepseek-7f3a9c2e1b",
|
||||
},
|
||||
{
|
||||
id: "model_002",
|
||||
name: "Qwen-Max",
|
||||
modelId: "qwen-max",
|
||||
type: "LLM",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-qwen-4d8e2a6f0c",
|
||||
},
|
||||
{
|
||||
id: "model_003",
|
||||
name: "讯飞语音识别",
|
||||
modelId: "iat",
|
||||
type: "ASR",
|
||||
interfaceType: "xfyun",
|
||||
apiUrl: "https://iat-api.xfyun.cn/v2/iat",
|
||||
apiKey: "xf-asr-9b1c3d5e7a",
|
||||
},
|
||||
{
|
||||
id: "model_004",
|
||||
name: "Paraformer 识别",
|
||||
modelId: "paraformer-realtime-v2",
|
||||
type: "ASR",
|
||||
interfaceType: "dashscope",
|
||||
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/asr",
|
||||
apiKey: "sk-paraformer-2e4f6a",
|
||||
},
|
||||
{
|
||||
id: "model_005",
|
||||
name: "讯飞语音合成",
|
||||
modelId: "tts",
|
||||
type: "TTS",
|
||||
interfaceType: "xfyun",
|
||||
apiUrl: "https://tts-api.xfyun.cn/v2/tts",
|
||||
apiKey: "xf-tts-6c8a0b2d4f",
|
||||
},
|
||||
{
|
||||
id: "model_006",
|
||||
name: "CosyVoice 合成",
|
||||
modelId: "cosyvoice-v1",
|
||||
type: "TTS",
|
||||
interfaceType: "dashscope",
|
||||
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts",
|
||||
apiKey: "sk-cosyvoice-1a3c5e",
|
||||
},
|
||||
{
|
||||
id: "model_007",
|
||||
name: "OpenAI TTS",
|
||||
modelId: "tts-1",
|
||||
type: "TTS",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://api.openai.com/v1/audio/speech",
|
||||
apiKey: "sk-openai-tts-8f0a2c",
|
||||
},
|
||||
{
|
||||
id: "model_008",
|
||||
name: "GPT Realtime",
|
||||
modelId: "gpt-4o-realtime-preview",
|
||||
type: "Realtime",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://api.openai.com/v1/realtime",
|
||||
apiKey: "sk-realtime-3b5d7f9a1c",
|
||||
},
|
||||
{
|
||||
id: "model_009",
|
||||
name: "Gemini Live",
|
||||
modelId: "gemini-2.0-flash-live",
|
||||
type: "Realtime",
|
||||
interfaceType: "gemini",
|
||||
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
apiKey: "gm-live-5e7a9c1b3d",
|
||||
},
|
||||
{
|
||||
id: "model_010",
|
||||
name: "text-embedding-3",
|
||||
modelId: "text-embedding-3-small",
|
||||
type: "Embedding",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://api.openai.com/v1/embeddings",
|
||||
apiKey: "sk-embed-0c2e4a6f8b",
|
||||
},
|
||||
{
|
||||
id: "model_011",
|
||||
name: "Kimi-K2",
|
||||
modelId: "moonshot-v1-8k",
|
||||
type: "LLM",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://api.moonshot.cn/v1",
|
||||
apiKey: "sk-kimi-7a9c1e3b5d",
|
||||
},
|
||||
{
|
||||
id: "model_012",
|
||||
name: "BGE Embedding",
|
||||
modelId: "bge-m3",
|
||||
type: "Embedding",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "https://api.siliconflow.cn/v1/embeddings",
|
||||
apiKey: "sk-bge-2d4f6a8c0e",
|
||||
},
|
||||
];
|
||||
|
||||
type ModelForm = {
|
||||
name: string;
|
||||
modelId: string;
|
||||
type: ModelType;
|
||||
interfaceType: InterfaceType;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
const emptyForm: ModelForm = {
|
||||
name: "",
|
||||
modelId: "",
|
||||
type: "LLM",
|
||||
interfaceType: "openai",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
};
|
||||
|
||||
type TypeFilter = "全部" | ModelType;
|
||||
|
||||
const typeFilters: TypeFilter[] = ["全部", ...modelTypes];
|
||||
|
||||
export function ComponentsModelsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ModelForm>(emptyForm);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
|
||||
|
||||
function updateForm<K extends keyof ModelForm>(key: K, value: ModelForm[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
|
||||
setTestResult("idle");
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
setTesting(true);
|
||||
setTestResult("idle");
|
||||
try {
|
||||
// TODO: 接入真实疎通接口(按 form.interfaceType 区分调用方式)
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
const reachable = Boolean(form.apiUrl.trim() && form.apiKey.trim());
|
||||
setTestResult(reachable ? "ok" : "fail");
|
||||
} catch {
|
||||
setTestResult("fail");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTypeChange(type: ModelType) {
|
||||
const options = interfaceOptionsByType[type];
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
type,
|
||||
// 资源类型变化时,若当前接口类型不在新类型的可选项内则重置为首项
|
||||
interfaceType: options.includes(prev.interfaceType)
|
||||
? prev.interfaceType
|
||||
: options[0],
|
||||
}));
|
||||
setTestResult("idle");
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setShowKey(false);
|
||||
setTestResult("idle");
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(model: ModelResource) {
|
||||
setEditingId(model.id);
|
||||
setForm({
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
type: model.type,
|
||||
interfaceType: model.interfaceType,
|
||||
apiUrl: model.apiUrl,
|
||||
apiKey: model.apiKey,
|
||||
});
|
||||
setShowKey(false);
|
||||
setTestResult("idle");
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
const filteredModels = mockModels.filter((model) => {
|
||||
if (typeFilter !== "全部" && model.type !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keyword = searchQuery.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [model.name, model.modelId, model.type, model.interfaceType, model.apiUrl]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
|
||||
const pageSize = 5;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||||
const pageEnd = pageStart + pageSize;
|
||||
const paginatedModels = filteredModels.slice(pageStart, pageEnd);
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
function handleFilterChange(filter: TypeFilter) {
|
||||
setTypeFilter(filter);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
const interfaceOptions = interfaceOptionsByType[form.type];
|
||||
const canSave =
|
||||
form.name.trim() && form.modelId.trim() && form.apiUrl.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||||
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6">
|
||||
<div>
|
||||
<h1 className="font-display display-lg text-ink">模型资源</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
||||
统一管理 LLM、ASR、TTS、Realtime 与 Embedding 模型,配置各自的接口类型与访问凭证。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full shrink-0 gap-2 sm:w-auto"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus size={16} />
|
||||
添加模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{typeFilters.map((filter) => (
|
||||
<Button
|
||||
key={filter}
|
||||
variant={filter === typeFilter ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={
|
||||
filter === typeFilter
|
||||
? "rounded-full"
|
||||
: "rounded-full border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
onClick={() => handleFilterChange(filter)}
|
||||
>
|
||||
{filter}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full lg:w-[320px]">
|
||||
<Search
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
|
||||
/>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
|
||||
placeholder="搜索模型名称、接口类型或 URL..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-hairline">
|
||||
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
|
||||
<div className="caption-label flex-1 text-muted-soft">模型名称</div>
|
||||
<div className="caption-label w-[110px] text-muted-soft">
|
||||
资源类型
|
||||
</div>
|
||||
<div className="caption-label w-[110px] text-muted-soft">
|
||||
接口类型
|
||||
</div>
|
||||
<div className="caption-label w-[260px] text-muted-soft">
|
||||
API URL
|
||||
</div>
|
||||
<div className="caption-label w-[116px] text-right text-muted-soft">
|
||||
操作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-hairline">
|
||||
{paginatedModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{model.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-soft">
|
||||
{model.modelId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:w-[110px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||
>
|
||||
{model.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="md:w-[110px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||
>
|
||||
{model.interfaceType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="truncate text-muted-foreground md:w-[260px]">
|
||||
{model.apiUrl}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 md:w-[116px]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEdit(model)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${model.name} 更多操作`}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem variant="destructive" className="rounded-lg">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 && (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<div className="font-medium text-foreground">
|
||||
未找到匹配的模型
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
请调整关键词或筛选条件后再试。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
{filteredModels.length === 0
|
||||
? "没有数据"
|
||||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredModels.length)} / 共 ${filteredModels.length} 个模型`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={safeCurrentPage <= 1}
|
||||
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
||||
(page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={page === safeCurrentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={[
|
||||
"h-8 min-w-8 px-2",
|
||||
page === safeCurrentPage
|
||||
? ""
|
||||
: "border-hairline-strong text-muted-foreground hover:text-foreground",
|
||||
].join(" ")}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={safeCurrentPage >= totalPages}
|
||||
onClick={() =>
|
||||
setCurrentPage((page) => Math.min(totalPages, page + 1))
|
||||
}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
填写资源名称、模型 ID、资源类型、接口类型与访问凭证。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-name"
|
||||
hint={{
|
||||
description:
|
||||
"在控制台中展示的资源别名,便于识别和管理,可与模型 ID 不同。",
|
||||
example: "DeepSeek-V3、Qwen-Max",
|
||||
}}
|
||||
>
|
||||
资源名称
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="model-name"
|
||||
value={form.name}
|
||||
autoFocus
|
||||
onChange={(event) => updateForm("name", event.target.value)}
|
||||
placeholder="请输入资源名称"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-id"
|
||||
hint={{
|
||||
description:
|
||||
"调用 API 时传入的模型标识,需与服务商文档中的名称完全一致。",
|
||||
example: "deepseek-chat、qwen-max、paraformer-realtime-v2",
|
||||
}}
|
||||
>
|
||||
模型 ID
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="model-id"
|
||||
value={form.modelId}
|
||||
onChange={(event) =>
|
||||
updateForm("modelId", event.target.value)
|
||||
}
|
||||
placeholder="例如 deepseek-chat"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
hint={{
|
||||
description:
|
||||
"模型在语音管线中的能力分类,决定可选的接口类型与后续编排用途。",
|
||||
example: "LLM、ASR、TTS、Realtime、Embedding",
|
||||
}}
|
||||
>
|
||||
资源类型
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(value) => handleTypeChange(value as ModelType)}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
||||
<SelectValue placeholder="请选择资源类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
||||
{modelTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
hint={{
|
||||
description:
|
||||
"服务商 API 的协议或适配器类型,需与所选资源类型匹配。",
|
||||
example: "openai、xfyun、dashscope、gemini",
|
||||
}}
|
||||
>
|
||||
接口类型
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={form.interfaceType}
|
||||
onValueChange={(value) =>
|
||||
updateForm("interfaceType", value as InterfaceType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
||||
<SelectValue placeholder="请选择接口类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
||||
{interfaceOptions.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-api-url"
|
||||
hint={{
|
||||
description:
|
||||
"模型服务的 Base URL 或接口根地址,通常以 /v1 结尾,不含具体路径参数。",
|
||||
example: "https://api.deepseek.com/v1",
|
||||
}}
|
||||
>
|
||||
API URL
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="model-api-url"
|
||||
value={form.apiUrl}
|
||||
onChange={(event) => updateForm("apiUrl", event.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-api-key"
|
||||
hint={{
|
||||
description:
|
||||
"访问模型服务的鉴权密钥,由服务商控制台生成,请妥善保管勿泄露。",
|
||||
example: "sk-xxxxxxxx",
|
||||
}}
|
||||
>
|
||||
API Key
|
||||
</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="model-api-key"
|
||||
value={form.apiKey}
|
||||
type={showKey ? "text" : "password"}
|
||||
onChange={(event) => updateForm("apiKey", event.target.value)}
|
||||
placeholder="请输入 API Key"
|
||||
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((value) => !value)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground"
|
||||
aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={!canSave || testing}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Plug size={14} />
|
||||
)}
|
||||
测试连接
|
||||
</Button>
|
||||
{testResult === "ok" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-500">
|
||||
<CheckCircle2 size={14} />
|
||||
连接成功
|
||||
</span>
|
||||
)}
|
||||
{testResult === "fail" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<XCircle size={14} />
|
||||
连接失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!canSave}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
<Brain size={16} />
|
||||
{editingId ? "保存" : "添加"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
htmlFor,
|
||||
children,
|
||||
hint,
|
||||
}: {
|
||||
htmlFor?: string;
|
||||
children: ReactNode;
|
||||
hint: { description: string; example?: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
<HelpHint {...hint} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpHint({
|
||||
description,
|
||||
example,
|
||||
}: {
|
||||
description: string;
|
||||
example?: string;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看说明"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 space-y-2 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
<p>{description}</p>
|
||||
{example && (
|
||||
<p className="text-xs leading-5 text-muted-soft">
|
||||
<span className="font-medium text-muted-foreground">填写样例:</span>
|
||||
{example}
|
||||
</p>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/ComponentsToolsPage.tsx
Normal file
10
frontend/src/components/pages/ComponentsToolsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function ComponentsToolsPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="工具资源"
|
||||
description="统一管理大语言模型、语音识别、声音资源、知识库与工具插件。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/DashboardPage.tsx
Normal file
10
frontend/src/components/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="数据看板"
|
||||
description="查看助手的对话数据、运行日志与调用明细。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/HistoryPage.tsx
Normal file
10
frontend/src/components/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function HistoryPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="历史记录"
|
||||
description="查看助手的对话历史、运行日志与调用明细。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/pages/HomePage.tsx
Normal file
65
frontend/src/components/pages/HomePage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Boxes, Plus } from "lucide-react";
|
||||
import type { NavKey } from "@/components/layout/AppShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type HomePageProps = {
|
||||
onNavigate: (key: NavKey) => void;
|
||||
};
|
||||
|
||||
export function HomePage({ onNavigate }: HomePageProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-12 pt-[3vh]">
|
||||
{/* Hero band — atmospheric gradient orbs behind editorial display copy */}
|
||||
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft px-10 py-16">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 -top-24 h-80 w-80 rounded-full opacity-60 blur-3xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 55%, transparent), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-28 left-10 h-72 w-72 rounded-full opacity-50 blur-3xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-lavender) 55%, transparent), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<h1 className="font-display display-xl text-ink">
|
||||
你好,开始管理你的智能视频助手
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-xl text-[16px] leading-7 text-body">
|
||||
在这里创建、配置、测试并发布 AI
|
||||
视频助手,统一管理模型、语音识别、声音资源、知识库与工具插件。
|
||||
</p>
|
||||
|
||||
<div className="mt-9 flex gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2 px-5"
|
||||
onClick={() => onNavigate("assistants")}
|
||||
>
|
||||
<Plus size={17} />
|
||||
创建助手
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||
onClick={() => onNavigate("components-models")}
|
||||
>
|
||||
<Boxes size={17} />
|
||||
配置模型
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/pages/PlaceholderPage.tsx
Normal file
47
frontend/src/components/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
type PlaceholderPageProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="font-display display-lg text-ink">{title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft px-10 py-20 text-center">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full opacity-50 blur-3xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 50%, transparent), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -left-20 bottom-0 h-64 w-64 rounded-full opacity-45 blur-3xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-lavender) 50%, transparent), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="caption-label inline-flex rounded-full bg-surface-strong px-3 py-1 text-muted-foreground">
|
||||
建设中
|
||||
</div>
|
||||
<p className="font-display display-sm mx-auto mt-5 max-w-md text-ink">
|
||||
这个页面正在编写中
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-md text-sm leading-7 text-body">
|
||||
页面骨架与设计语言已就绪,后续将填充具体功能与数据。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/ProfilePage.tsx
Normal file
10
frontend/src/components/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function ProfilePage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="个人中心"
|
||||
description="管理账户信息、团队成员、密钥与偏好设置。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/TestPage.tsx
Normal file
10
frontend/src/components/pages/TestPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function TestPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="测试助手"
|
||||
description="在发布前通过实时视频对话测试助手的表现与交互体验。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/pages/WorkflowPage.tsx
Normal file
10
frontend/src/components/pages/WorkflowPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function WorkflowPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="工作流"
|
||||
description="管理与编排可复用的助手工作流,支持多轮任务与工具调用。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/ui/badge.tsx
Normal file
49
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
65
frontend/src/components/ui/button.tsx
Normal file
65
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
100
frontend/src/components/ui/card.tsx
Normal file
100
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-6 overflow-hidden rounded-4xl bg-card py-6 text-sm text-card-foreground shadow-md ring-1 ring-foreground/5 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-4xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("font-heading text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-4xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
168
frontend/src/components/ui/dialog.tsx
Normal file
168
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
269
frontend/src/components/ui/dropdown-menu.tsx
Normal file
269
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
19
frontend/src/components/ui/input.tsx
Normal file
19
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
42
frontend/src/components/ui/popover.tsx
Normal file
42
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl border border-hairline bg-popover p-4 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
195
frontend/src/components/ui/select.tsx
Normal file
195
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1.5 p-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-3xl border border-transparent bg-input/50 px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"pointer-events-none -mx-1.5 my-1.5 h-px bg-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
frontend/src/components/ui/separator.tsx
Normal file
28
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
147
frontend/src/components/ui/sheet.tsx
Normal file
147
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
705
frontend/src/components/ui/sidebar.tsx
Normal file
705
frontend/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,705 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-input/50 shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn(
|
||||
"flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 text-sm",
|
||||
sm: "h-8 text-xs",
|
||||
lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-xl px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-xl"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
frontend/src/components/ui/skeleton.tsx
Normal file
13
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-2xl bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
33
frontend/src/components/ui/switch.tsx
Normal file
33
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border-2 transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-11 data-[size=sm]:h-4 data-[size=sm]:w-7 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-unchecked:border-transparent data-unchecked:bg-input/90 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:h-4 group-data-[size=default]/switch:w-6 group-data-[size=sm]/switch:h-3 group-data-[size=sm]/switch:w-4 data-checked:translate-x-[calc(100%-8px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
90
frontend/src/components/ui/tabs.tsx
Normal file
90
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full p-1 text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-2 rounded-full border border-transparent! px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:rounded-2xl group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-1.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full resize-none rounded-2xl border border-transparent bg-input/50 px-3 py-3 text-base transition-[color,box-shadow,background-color] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
57
frontend/src/components/ui/tooltip.tsx
Normal file
57
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
270
frontend/src/components/ui/voice-visualizer.tsx
Normal file
270
frontend/src/components/ui/voice-visualizer.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type VoiceVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风(便于复用现有音频链路) */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px) */
|
||||
size?: number;
|
||||
/** 环绕的频谱柱数量 */
|
||||
barCount?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
function parseHex(hex: string, fallback: RGB): RGB {
|
||||
const m = hex.trim().replace("#", "");
|
||||
if (m.length === 6) {
|
||||
return {
|
||||
r: parseInt(m.slice(0, 2), 16),
|
||||
g: parseInt(m.slice(2, 4), 16),
|
||||
b: parseInt(m.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
if (m.length === 3) {
|
||||
return {
|
||||
r: parseInt(m[0] + m[0], 16),
|
||||
g: parseInt(m[1] + m[1], 16),
|
||||
b: parseInt(m[2] + m[2], 16),
|
||||
};
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function rgba({ r, g, b }: RGB, a: number) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
function mix(a: RGB, b: RGB, t: number): RGB {
|
||||
return {
|
||||
r: Math.round(a.r + (b.r - a.r) * t),
|
||||
g: Math.round(a.g + (b.g - a.g) * t),
|
||||
b: Math.round(a.b + (b.b - a.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在一组首尾相连的颜色之间取环形插值 */
|
||||
function cyclicColor(stops: RGB[], p: number): RGB {
|
||||
const n = stops.length;
|
||||
const scaled = (p % 1) * n;
|
||||
const i = Math.floor(scaled);
|
||||
return mix(stops[i % n], stops[(i + 1) % n], scaled - i);
|
||||
}
|
||||
|
||||
export function VoiceVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 200,
|
||||
barCount = 72,
|
||||
onError,
|
||||
className,
|
||||
}: VoiceVisualizerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const analyserRef = React.useRef<AnalyserNode | null>(null);
|
||||
const smoothRef = React.useRef<Float32Array>(new Float32Array(barCount));
|
||||
const onErrorRef = React.useRef(onError);
|
||||
React.useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
// 音频接入:优先用外部 analyser / stream,否则在 active 时申请麦克风
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let ctx: AudioContext | null = null;
|
||||
let micStream: MediaStream | null = null;
|
||||
|
||||
async function setup() {
|
||||
if (analyser) {
|
||||
analyserRef.current = analyser;
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
analyserRef.current = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const AudioCtor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
ctx = new AudioCtor();
|
||||
|
||||
let source: MediaStreamAudioSourceNode;
|
||||
if (stream) {
|
||||
source = ctx.createMediaStreamSource(stream);
|
||||
} else {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (cancelled) {
|
||||
micStream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
source = ctx.createMediaStreamSource(micStream);
|
||||
}
|
||||
|
||||
const node = ctx.createAnalyser();
|
||||
node.fftSize = 256;
|
||||
node.smoothingTimeConstant = 0.82;
|
||||
source.connect(node);
|
||||
analyserRef.current = node;
|
||||
} catch (error) {
|
||||
if (!cancelled) onErrorRef.current?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
analyserRef.current = analyser ?? null;
|
||||
micStream?.getTracks().forEach((track) => track.stop());
|
||||
ctx?.close().catch(() => {});
|
||||
};
|
||||
}, [active, analyser, stream]);
|
||||
|
||||
// 绘制循环
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
if (smoothRef.current.length !== barCount) {
|
||||
smoothRef.current = new Float32Array(barCount);
|
||||
}
|
||||
const smooth = smoothRef.current;
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const orbR = size * 0.2;
|
||||
const maxBar = size * 0.18;
|
||||
const freq = new Uint8Array(128);
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
|
||||
const draw = () => {
|
||||
t += 0.016;
|
||||
|
||||
const fallbackSky = { r: 95, g: 134, b: 184 };
|
||||
const fallbackLav = { r: 138, g: 120, b: 173 };
|
||||
const fallbackRose = { r: 176, g: 125, b: 140 };
|
||||
const style = getComputedStyle(canvas);
|
||||
const sky = parseHex(
|
||||
style.getPropertyValue("--gradient-sky"),
|
||||
fallbackSky,
|
||||
);
|
||||
const lav = parseHex(
|
||||
style.getPropertyValue("--gradient-lavender"),
|
||||
fallbackLav,
|
||||
);
|
||||
const rose = parseHex(
|
||||
style.getPropertyValue("--gradient-rose"),
|
||||
fallbackRose,
|
||||
);
|
||||
const palette = [sky, lav, rose];
|
||||
|
||||
const node = analyserRef.current;
|
||||
if (node) {
|
||||
node.getByteFrequencyData(freq);
|
||||
}
|
||||
|
||||
let energy = 0;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let target: number;
|
||||
if (node) {
|
||||
// 取低中频段(人声主要能量)映射到环形
|
||||
const bin = Math.floor((i / barCount) * (freq.length * 0.7));
|
||||
target = freq[bin] / 255;
|
||||
} else {
|
||||
// 静态呼吸态
|
||||
target = 0.1 + 0.06 * (0.5 + 0.5 * Math.sin(t * 1.6 + i * 0.35));
|
||||
}
|
||||
smooth[i] += (target - smooth[i]) * 0.28;
|
||||
energy += smooth[i];
|
||||
}
|
||||
energy /= barCount;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.4);
|
||||
const pulse = 1 + energy * 0.12 + breathe * 0.02;
|
||||
|
||||
// 外层光晕
|
||||
const glow = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbR * 0.3,
|
||||
cx,
|
||||
cy,
|
||||
size * 0.5,
|
||||
);
|
||||
glow.addColorStop(0, rgba(sky, 0.18 + energy * 0.35));
|
||||
glow.addColorStop(0.55, rgba(lav, 0.08 + energy * 0.18));
|
||||
glow.addColorStop(1, rgba(lav, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// 环形频谱柱
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = Math.max(2, size * 0.012);
|
||||
const rotation = t * 0.15;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const angle = (i / barCount) * Math.PI * 2 + rotation;
|
||||
const v = smooth[i];
|
||||
const r0 = orbR * pulse + size * 0.03;
|
||||
const r1 = r0 + maxBar * v + 1.5;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.55 + v * 0.4);
|
||||
ctx.shadowColor = rgba(color, 0.8);
|
||||
ctx.shadowBlur = 6 + v * 16;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 中心柔光:随音量发亮,不画实体球
|
||||
const coreR = orbR * pulse;
|
||||
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR);
|
||||
core.addColorStop(0, rgba(sky, 0.22 + energy * 0.4));
|
||||
core.addColorStop(0.6, rgba(lav, 0.06 + energy * 0.15));
|
||||
core.addColorStop(1, rgba(lav, 0));
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = core;
|
||||
ctx.fill();
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [size, barCount]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label="麦克风音频可视化"
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("select-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
0
frontend/src/data/mock.ts
Normal file
0
frontend/src/data/mock.ts
Normal file
19
frontend/src/hooks/use-mobile.ts
Normal file
19
frontend/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
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
0
frontend/src/types/index.ts
Normal file
0
frontend/src/types/index.ts
Normal file
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user