第一次提交

This commit is contained in:
2026-03-13 16:40:08 +08:00
commit 0a49bcfb2d
277 changed files with 61890 additions and 0 deletions

View File

@ -0,0 +1,3 @@
{
"template": "nextjs-shadcn"
}

View File

@ -0,0 +1,2 @@
components/ui/*
hooks/use-toast.ts

View File

@ -0,0 +1,9 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
When using client-side hooks (useState and useEffect) in a component that's being treated as a Server Component by Next.js, always add the "use client" directive at the top of the file.
Do not write code that will trigger this error: "Warning: Extra attributes from the server: %s%s""class,style"
By default, this template supports JSX syntax with Tailwind CSS classes, the shadcn/ui library, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.

View File

@ -0,0 +1,7 @@
node_modules
.next
.env.local
.env.development.local
.env.test.local
.env.production.local
.git

View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_API_URL=http://10.6.80.29:10000
NEXT_PUBLIC_WS_URL=wss://10.6.80.29:10000

View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

37
app-instance/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/.next-dev/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -0,0 +1,51 @@
# 统一主版本Next 15 建议 Node 20
ARG NODE_VERSION=20
FROM node:20-alpine AS base
WORKDIR /app
ENV CI=1 NEXT_TELEMETRY_DISABLED=1
# 1) 安装依赖
FROM base AS deps
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN set -eux; \
if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm i --no-frozen-lockfile --registry=http://registry.npm.taobao.org; \
elif [ -f yarn.lock ]; then yarn --no-frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci --registry=http://registry.npm.taobao.org; \
else echo "Lockfile not found." && exit 1; fi
# 2) 模块解析“早失败”校验
FROM base AS verify
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
RUN npm run -s verify:modules
# 3) 质量检查lint / typecheck
FROM base AS quality
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run -s lint
RUN npm run -s typecheck
# 4) 生产构建
FROM base AS builder
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run -s build
# 5) 运行镜像(与构建版本一致)
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT=3000
CMD ["server.js"]

View File

@ -0,0 +1,793 @@
# Frontend Multi-Agent / MCP Process UI Change
## 1. 目标
前端聊天页要从“单一消息流”升级成“可视化协作工作台”,让用户在一次聊天里同时看到:
1. 主对话区里的用户问题与最终总结回复。
2. 每个 sub-agent / A2A agent / MCP server 的独立处理框。
3. agent 之间的流式进展、状态变化、问答片段。
4. MCP 工具调用产物,例如文本结果、结构化 JSON、文件、链接、图片。
5. 一个固定的结果侧栏,用来汇总当前运行中的过程结果与最终产物。
6. 独立的 Agent 管理页和 MCP 管理页,体验上与现有 `skills` / `plugins` 页面一致。
这个需求本质上不是“把聊天页面做复杂一点”,而是要把聊天 UI 的数据模型从 `messages[]` 升级成 `messages[] + process runs[] + artifacts[] + actor registry[]`
## 2. 当前现状
### 2.1 前端现状
当前前端的核心限制如下:
- 聊天页集中在 `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx`
- 聊天状态只在 `/home/ivan/xuan/steven_project/nanobot-fronted/lib/store.ts` 里维护:
- `messages`
- `isLoading`
- `isThinking`
- `streamingContent`
- WebSocket 只在 `/home/ivan/xuan/steven_project/nanobot-fronted/lib/api.ts` 里处理非常薄的一层消息:
- `type=status`
- `type=message`
- 类型定义 `/home/ivan/xuan/steven_project/nanobot-fronted/types/index.ts` 没有“运行事件 / agent 卡片 / artifact / process timeline”概念。
- 顶部导航 `/home/ivan/xuan/steven_project/nanobot-fronted/components/Header.tsx` 目前没有 `Agents` / `MCP` 页入口。
- 现有 `skills` / `plugins` 页面适合复用作管理页风格参考:
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/skills/page.tsx`
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/plugins/page.tsx`
### 2.2 后端现状
后端已经具备部分多 agent 能力,但还不够支撑前端过程可视化:
- 已有统一 agent 列表接口:`GET /api/agents`
- 位置:`/home/ivan/xuan/steven_project/nanobot-backend/nanobot/web/server.py`
- 已有 A2A / group delegation 逻辑:
- `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/agent/delegation.py`
- 已有 A2A streaming / resubscribe / cancel
- `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/a2a/client.py`
- 但当前对前端暴露的实时消息仍然只有:
- `status=thinking`
- `assistant message`
- `DelegationManager` 现在对外发布的也只是普通 `_progress` 文本,例如:
- `[AgentName] ...`
- MCP 目前只有后端连接配置和工具注册,没有独立的 Web 管理接口,也没有结构化 MCP 运行事件。
结论:
前端可以先做布局和状态层改造,但如果想真正展示“每个 agent 的框、每个 MCP 的产物、agent 间问答”,后端必须补一层结构化 process event 协议。只靠现在的纯文本 progress 不够。
## 3. 推荐的界面形态
桌面端建议改成三栏工作台,而不是继续沿用现在的单栏聊天布局。
### 3.1 桌面布局
```text
┌──────────────┬───────────────────────────────────────┬──────────────────────────┐
│ 会话侧栏 │ 主聊天 + 过程泳道 │ 结果侧栏 │
│ Sessions │ │ Results / Artifacts │
│ │ 用户消息 │ 当前运行摘要 │
│ │ assistant 最终总结 │ agent 产物列表 │
│ │ ─────────────────────────────────── │ MCP 产物列表 │
│ │ Agent A 卡片 │ 文件/图片/JSON 预览 │
│ │ Agent B 卡片 │ 错误/告警 │
│ │ MCP github 卡片 │ 最终汇总结论 │
│ │ MCP browser 卡片 │ │
└──────────────┴───────────────────────────────────────┴──────────────────────────┘
```
### 3.2 移动端布局
移动端不要硬保留三栏:
1. 主聊天区保留为默认视图。
2. 过程泳道和结果侧栏改成底部 `Tabs``Drawer`
3. 正在运行时,顶部显示一个 `Process (3)` 悬浮入口。
### 3.3 视觉原则
不要把过程信息混成普通 assistant markdown。
应明确区分三类对象:
1. `Chat Message`:用户问题、最终总结。
2. `Process Card`:某个 agent 或 MCP 的运行容器。
3. `Artifact`:某个步骤产出的结构化结果。
建议:
- Agent 卡片用清晰的状态边框和标题区。
- MCP 卡片强调“工具/服务器”属性,避免与 agent 混淆。
- 结果侧栏始终可见,展示当前选中卡片的详细结果。
## 4. 目标交互
### 4.1 单 Agent
用户发出问题后:
1. 主聊天区出现用户消息。
2. assistant 进入 `thinking`
3. 若命中 `spawn` / A2A delegation过程泳道新增一个 Agent 卡片。
4. 卡片内部流式更新:
- 状态queued / running / waiting / done / error / cancelled
- 文本片段
- agent 生成的中间消息
- 关键参数或结果摘要
5. 如果 agent 调了 MCP再在该卡片内部挂子步骤或在泳道新增 MCP 卡片。
6. 右侧结果栏展示:
- 当前 agent 的最新摘要
- 产物列表
- 可预览文件
7. 所有 agent 结束后,主 assistant 再给一条最终总结回复。
### 4.2 多 Agent Group
如果是 group delegation
1. 过程泳道里要同时出现多个 Agent 卡片。
2. 每个卡片独立流式刷新,不要合并成一条文本。
3. 结果侧栏支持切换:
- `All`
- `Agent A`
- `Agent B`
- `MCP Outputs`
4. 最终 assistant 总结要包含:
- 共识
- 分歧
- 失败项
- 最终建议
### 4.3 Agent 间“一问一答”
如果未来后端能发出 agent-to-agent message event前端直接把这些消息渲染到卡片里的 transcript 区。
建议 UI 表现:
- 卡片头agent 名称、来源、状态、耗时
- 卡片体:
- `Transcript`
- `Steps`
- `Artifacts`
- 卡片尾:最终摘要 / 错误信息
## 5. 前端改造点
## 5.1 先不要继续把逻辑堆进 `app/page.tsx`
当前 `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx` 已经过大。这个需求如果继续直接堆,会很快失控。
建议拆分。
### 5.2 建议新增的组件与文件
建议新增目录:
- `components/chat-workbench/ChatWorkbench.tsx`
- `components/chat-workbench/ProcessLane.tsx`
- `components/chat-workbench/ProcessRunCard.tsx`
- `components/chat-workbench/ProcessTranscript.tsx`
- `components/chat-workbench/ArtifactSidebar.tsx`
- `components/chat-workbench/RunSummaryPanel.tsx`
- `components/chat-workbench/AgentBadge.tsx`
- `components/chat-workbench/McpBadge.tsx`
- `components/chat-workbench/StatusPill.tsx`
建议职责:
- `ChatWorkbench.tsx`
- 负责三栏布局组合。
- `ProcessLane.tsx`
- 渲染当前 session 的所有 process run。
- `ProcessRunCard.tsx`
- 渲染单个 agent / MCP 卡片。
- `ProcessTranscript.tsx`
- 渲染步骤流、问答片段、进度文本。
- `ArtifactSidebar.tsx`
- 渲染右侧产物栏。
- `RunSummaryPanel.tsx`
- 展示当前 run 的状态概览和最终摘要。
### 5.3 对现有文件的插入建议
#### `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx`
保留职责:
- session 列表
- 输入框
- 顶层页面组织
减少职责:
- 不再在这里直接渲染复杂过程 UI
- 不再在这里直接解析 process 事件
建议修改为:
1. 左侧会话侧栏基本保留。
2. 中间改成 `<ChatWorkbench />`
3. `MessageBubble` 可以保留,但只负责普通 `user/assistant` 消息。
4. 新增一个 `selectedRunId` / `selectedArtifactId` 的页面级状态,或者放进 Zustand store。
#### `/home/ivan/xuan/steven_project/nanobot-fronted/lib/store.ts`
这里需要从“聊天 store”升级成“聊天 + 过程 store”。
建议新增状态:
- `processRuns: ProcessRun[]`
- `processEvents: ProcessEvent[]`
- `artifacts: ProcessArtifact[]`
- `selectedRunId: string | null`
- `selectedArtifactId: string | null`
- `activeRunIds: string[]`
- `agentRegistry: UiAgentDescriptor[]`
- `mcpRegistry: UiMcpServerDescriptor[]`
建议新增 action
- `resetProcessState(sessionId)`
- `upsertProcessRun(run)`
- `appendProcessEvent(event)`
- `appendProcessArtifact(artifact)`
- `finishProcessRun(runId, status)`
- `cancelProcessRun(runId)`
- `setSelectedRunId(runId)`
- `setSelectedArtifactId(artifactId)`
- `setAgentRegistry(agents)`
- `setMcpRegistry(servers)`
#### `/home/ivan/xuan/steven_project/nanobot-fronted/types/index.ts`
这里需要新增完整类型层。
建议新增:
```ts
export type ProcessActorType = 'agent' | 'mcp' | 'system';
export type ProcessRunStatus = 'queued' | 'running' | 'waiting' | 'done' | 'error' | 'cancelled';
export type ProcessEventKind =
| 'run_started'
| 'run_progress'
| 'run_message'
| 'run_artifact'
| 'run_status'
| 'run_finished'
| 'run_cancelled';
export interface UiAgentDescriptor {
id: string;
name: string;
description: string;
source: 'workspace' | 'plugin' | 'skill' | 'builtin';
kind: string;
protocol: string | null;
tags: string[];
aliases: string[];
support_group: boolean;
support_streaming: boolean;
}
export interface UiMcpServerDescriptor {
id: string;
name: string;
transport: 'stdio' | 'http';
url?: string;
command?: string;
enabled: boolean;
tool_count?: number;
tool_names?: string[];
status?: 'connected' | 'disconnected' | 'error';
last_error?: string | null;
}
export interface ProcessRun {
run_id: string;
parent_run_id?: string | null;
session_id: string;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
title: string;
status: ProcessRunStatus;
started_at: string;
finished_at?: string | null;
summary?: string | null;
source?: string | null;
}
export interface ProcessEvent {
event_id: string;
run_id: string;
parent_run_id?: string | null;
kind: ProcessEventKind;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
text?: string;
status?: ProcessRunStatus;
message_role?: 'system' | 'user' | 'assistant' | 'tool';
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessArtifact {
artifact_id: string;
run_id: string;
actor_type: ProcessActorType;
actor_id: string;
title: string;
artifact_type: 'text' | 'json' | 'file' | 'image' | 'link' | 'markdown';
content?: string;
data?: Record<string, unknown> | unknown[];
file_id?: string;
url?: string;
created_at: string;
}
```
#### `/home/ivan/xuan/steven_project/nanobot-fronted/lib/api.ts`
这里要扩展三类能力:
1. Agent 管理 API
2. MCP 管理 API
3. WebSocket process event 订阅
建议新增:
- `listAgents()`
- `addAgent()`
- `deleteAgent()`
- `refreshAgents()`
- `listMcpServers()`
- `addMcpServer()`
- `updateMcpServer()`
- `deleteMcpServer()`
- `testMcpServer()`
同时把 `WsMessageHandler` 从现在的宽松结构,升级成联合类型:
```ts
export type WsEvent =
| ChatAssistantEvent
| ChatThinkingEvent
| ProcessRunStartedEvent
| ProcessRunUpdatedEvent
| ProcessArtifactEvent
| ProcessRunFinishedEvent
| ProcessRunCancelledEvent;
```
#### `/home/ivan/xuan/steven_project/nanobot-fronted/components/Header.tsx`
导航中建议新增:
- `/agents`
- `/mcp`
放在 `skills` / `plugins` 旁边,不要塞进聊天页内部。
### 5.4 建议新增页面
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/agents/page.tsx`
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/mcp/page.tsx`
Agent 页面参考 `skills + plugins` 的中间态:
- 列表视图
- 支持新增、删除、刷新
- 展示来源workspace / plugin / skill / builtin
- 展示协议a2a / local
- 展示标签、别名、streaming/group 支持
MCP 页面建议分两块:
1. `Configured Servers`
2. `Discovered Tools`
每个 MCP server 展示:
- 连接方式stdio / http
- 地址或命令
- tool 数量
- 连接状态
- 最后错误
- 编辑/删除/测试按钮
## 6. 聊天页的推荐逻辑链路
这是前端应当遵守的主链路。
### 6.1 用户发消息
1. 用户在 `app/page.tsx` 输入消息。
2. 立即写入 `messages[]`
3. 设置 `isLoading=true`
4. 如果 WebSocket 已连接,消息通过 `wsManager.sendRaw()` 发出去。
5. 前端等待两类数据:
- 普通 assistant reply
- process events
### 6.2 触发 sub-agent / group / MCP
后端一旦进入 delegation / MCP tool 调用,应向前端发结构化 process event。
前端收到后:
1. `run_started` -> 创建卡片。
2. `run_progress` -> 更新卡片中的 transcript。
3. `run_artifact` -> 写入右侧侧栏。
4. `run_status` -> 更新状态 pill。
5. `run_finished` -> 收起 loading保留结果。
6. 最终 `assistant message` -> 输出总结性回复。
### 6.3 用户点击某个 Agent / MCP 卡片
1. 设置 `selectedRunId`
2. 右侧 `ArtifactSidebar` 切换到该 run 的 artifact 列表。
3. 中间卡片高亮。
4. 若有 transcript则显示完整流。
### 6.4 用户取消运行
如果后端暴露 cancel 接口或 WebSocket cancel command
1. 卡片上显示 `Cancel`
2. 用户点击后发送 cancel 请求。
3. run 状态变为 `cancelled`
4. 侧栏保留已有产物,但标记“未完成”。
## 7. 后端必须补的事件协议
这是这次前端能否做成的关键。
当前后端只发普通文本 `_progress`,不够。
必须新增结构化 WebSocket 事件。建议统一成 `type=process_*`
### 7.1 建议的事件集合
#### `process_run_started`
```json
{
"type": "process_run_started",
"session_id": "web:default",
"run_id": "deleg-123",
"parent_run_id": null,
"actor_type": "agent",
"actor_id": "repo-reviewer",
"actor_name": "Repo Reviewer",
"source": "workspace",
"title": "Review auth refactor",
"status": "running",
"created_at": "2026-03-06T10:00:00Z"
}
```
#### `process_run_progress`
```json
{
"type": "process_run_progress",
"run_id": "deleg-123",
"actor_type": "agent",
"actor_id": "repo-reviewer",
"text": "Scanning auth middleware and session lifecycle",
"created_at": "2026-03-06T10:00:03Z"
}
```
#### `process_run_message`
用于展示 agent 间问答或 agent 内部消息。
```json
{
"type": "process_run_message",
"run_id": "deleg-123",
"actor_type": "agent",
"actor_id": "repo-reviewer",
"message_role": "assistant",
"text": "I need the gateway config file before deciding.",
"created_at": "2026-03-06T10:00:04Z"
}
```
#### `process_run_artifact`
```json
{
"type": "process_run_artifact",
"run_id": "mcp-456",
"actor_type": "mcp",
"actor_id": "github",
"title": "Pull Request Diff Summary",
"artifact_type": "markdown",
"content": "...",
"created_at": "2026-03-06T10:00:08Z"
}
```
#### `process_run_status`
```json
{
"type": "process_run_status",
"run_id": "deleg-123",
"status": "waiting",
"text": "Waiting for remote agent task completion",
"created_at": "2026-03-06T10:00:10Z"
}
```
#### `process_run_finished`
```json
{
"type": "process_run_finished",
"run_id": "deleg-123",
"status": "done",
"summary": "Found 2 risks in auth token refresh flow.",
"created_at": "2026-03-06T10:00:20Z"
}
```
#### `process_run_cancelled`
```json
{
"type": "process_run_cancelled",
"run_id": "deleg-123",
"status": "cancelled",
"created_at": "2026-03-06T10:00:12Z"
}
```
### 7.2 后端推荐插入点
如果你后面让我直接改前后端,我会从这些点切:
#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/agent/delegation.py`
这里最适合发 agent 级 process event
- `dispatch()` 开始时发 `process_run_started`
- `_build_progress_callback()` 中把 A2A stream 文本转成 `process_run_progress`
- `_run_group()` 中每个 descriptor 启动时发独立子 run
- `_announce_single_result()` 之前发 `process_run_finished`
- `_announce_group_result()` 之前发 group summary run finished
- `cancel()` / `_announce_cancelled()``process_run_cancelled`
#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/a2a/client.py`
这里最适合补更细的远端 agent 消息:
- `_consume_stream_method()`
- `_resume_subscription()`
如果远端流里有 message chunk / state / artifact就在这里归一化后向上抛给 `DelegationManager`
#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/agent/tools/mcp.py`
这里最适合发 MCP 级事件:
- MCP 工具调用开始 -> `process_run_started` (`actor_type=mcp`)
- 工具标准输出 / 中间结果 -> `process_run_progress`
- 工具返回文本 / JSON / 文件 -> `process_run_artifact`
- 工具调用完成 -> `process_run_finished`
- 超时 / 失败 -> `process_run_status` + `process_run_finished(status=error)`
#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/web/server.py`
这里需要扩展 WebSocket 发送协议,而不是只发 `thinking/message`
## 8. Agent 管理页方案
### 8.1 页面目标
让用户能像管理 `skills` 一样管理委派目标 agent。
### 8.2 数据来源
直接用现有接口:
- `GET /api/agents`
- `POST /api/agents`
- `DELETE /api/agents/{id}`
- `POST /api/agents/refresh`
### 8.3 页面布局建议
参考 `plugins` 页的卡片布局,但要比 `plugins` 更偏“资源管理”。
建议字段:
- 名称
- id
- description
- source
- protocol
- tags
- aliases
- support_group
- support_streaming
- endpoint / base_url / card_url
建议交互:
1. 顶部 `Refresh`
2. 顶部 `Add Agent`
3. 列表卡片
4. workspace agent 允许删除
5. plugin / skill / builtin agent 只读
### 8.4 Add Agent 弹窗字段
- `id`
- `name`
- `description`
- `protocol`,先只放 `a2a`
- `base_url`
- `endpoint`
- `card_url`
- `auth_env`
- `tags`
- `aliases`
- `enabled`
## 9. MCP 管理页方案
### 9.1 结论先说
这个页面前端不能单独完成,因为当前后端没有 MCP 管理 API。
所以文档给的是“前端页面方案 + 后端配套接口定义”。
### 9.2 后端建议增加的 API
建议新增:
- `GET /api/mcp/servers`
- `POST /api/mcp/servers`
- `PUT /api/mcp/servers/{id}`
- `DELETE /api/mcp/servers/{id}`
- `POST /api/mcp/servers/{id}/test`
- `GET /api/mcp/tools`
### 9.3 MCP server 返回结构建议
```json
{
"id": "github",
"name": "github",
"transport": "http",
"url": "http://localhost:3001/mcp",
"command": "",
"args": [],
"enabled": true,
"tool_timeout": 30,
"headers": {},
"status": "connected",
"tool_count": 12,
"tool_names": ["search_repos", "list_prs"],
"last_error": null
}
```
### 9.4 页面布局建议
上半区MCP servers
- 卡片或表格
- 编辑 / 删除 / 测试连接
下半区Discovered tools
- 按 server 分组
- 展示工具名、说明、schema 摘要
## 10. 建议的前端实现顺序
按这个顺序做最稳。
### Phase 1: 先做前端数据结构重构
1.`types/index.ts`
2.`lib/store.ts`
3.`lib/api.ts` 的 ws event 类型
4.`app/page.tsx` 拆出 `ChatWorkbench`
这一步即使后端结构化事件还没补,也可以先用 mock data 跑布局。
### Phase 2: 落三栏工作台 UI
1. 中间主聊天区保留现有 message bubble
2.`ProcessLane`
3.`ArtifactSidebar`
4. 支持选中某个 run
### Phase 3: 接后端 process events
1.`wsManager.onMessage()` 加 process 事件分发
2. store 按 event 更新 process state
3. 卡片流式刷新
### Phase 4: 新增 Agent 管理页
1. `app/agents/page.tsx`
2. `lib/api.ts` 增 Agent API
3. `Header.tsx` 增导航
### Phase 5: 新增 MCP 管理页
1. 后端先补接口
2. 前端 `app/mcp/page.tsx`
3. 管理 + 测试连接 + 工具查看
## 11. 为什么我建议最好由同一个 Codex 连前后端一起改
如果只是做视觉壳子,另一个 Codex 在前端仓库里单独改也可以。
但如果目标是你描述的完整体验:
- 每个 agent / MCP 弹出独立框
- 展示过程中的一问一答
- 展示 MCP 产物
- 最后再统一总结
那就不是纯前端问题,而是“后端事件模型 + 前端状态模型”联动问题。
结论:
- 只写前端:可以先做静态布局和 store 重构。
- 真正做成:最好同一个人连续改 backend + frontend避免事件协议和 UI 状态设计脱节。
## 12. 给另一个 Codex 的明确施工指令
如果你把这份文档交给另一个 Codex建议直接给它下面这段要求
1. 先阅读:
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx`
- `/home/ivan/xuan/steven_project/nanobot-fronted/lib/store.ts`
- `/home/ivan/xuan/steven_project/nanobot-fronted/lib/api.ts`
- `/home/ivan/xuan/steven_project/nanobot-fronted/types/index.ts`
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/plugins/page.tsx`
- `/home/ivan/xuan/steven_project/nanobot-fronted/app/skills/page.tsx`
2. 先把聊天页拆成三栏工作台,不要继续把复杂逻辑堆在 `app/page.tsx`
3. 先做 `processRuns / processEvents / artifacts` 的前端数据模型。
4. 先接 `/api/agents` 做 Agent 管理页。
5. MCP 管理页先按文档搭 UI 壳子,但要显式标记“依赖后端 MCP API”。
6. 如果要做真实过程可视化,不要拿普通 markdown 消息硬解析,必须等结构化 WebSocket process events。
## 13. 最小可交付版本
如果要先做一个能看的版本,建议这样收敛:
1. 聊天页先做三栏布局。
2. 用当前 `_progress` 文本先临时映射成 Agent 卡片日志。
3. 先接 `/api/agents` 做管理页。
4. MCP 页先做只读占位页,提示“等待后端 MCP API”。
5. 第二轮再补真正结构化 process events。
这条路径的好处是:
- UI 先起来
- 后端协议第二轮再精修
- 不会一开始就卡死在全链路联调上
## 14. 推荐文档结论
最合适的落地方式是:
1. 前端先重构成“聊天消息”和“过程运行”两套状态。
2. 聊天页改成三栏工作台。
3. 先接现有 `/api/agents` 做 Agent 管理页。
4. MCP 管理页需要后端先补接口。
5. 真正的过程可视化必须补结构化 WebSocket process events核心后端插入点是
- `nanobot/agent/delegation.py`
- `nanobot/a2a/client.py`
- `nanobot/agent/tools/mcp.py`
- `nanobot/web/server.py`

View File

@ -0,0 +1,269 @@
# Boardware Genius Frontend
这是 `Boardware Genius` 的前端项目,基于 Next.js 13 App Router 构建提供聊天工作台、登录注册、系统状态、定时任务、技能、插件、智能体、MCP、文件管理等页面。
这个仓库只负责前端界面和浏览器侧交互,不包含后端服务实现。前端通过 HTTP 和 WebSocket 与后端网关通信。
## 项目定位
- 面向 `Boardware Genius` 的 Web 控制台
- 提供统一的聊天入口和运维入口
- 支持多页面管理能力:
- 对话与会话管理
- 系统状态查看
- 定时任务管理
- 技能 / 插件 / 智能体 / MCP 管理
- 工作区文件浏览与上传下载
## 主要功能
### 1. 聊天工作台
- 左侧会话列表,支持切换、新建、删除会话
- 主聊天区支持文本输入、文件上传、命令提示
- WebSocket 实时接收消息和过程事件
- 展示任务执行过程、结构化事件和执行产物
### 2. 认证与访问控制
- 提供登录、注册页面
- 业务页与认证页使用不同 layout
- 通过 `AuthGuard` 保护业务页访问
- Access Token / Refresh Token 存在浏览器本地存储
### 3. 平台管理页面
- `状态`查看后端配置、模型、Provider、通道、调度器状态
- `定时任务`:新增、启停、执行、删除 Cron 任务
- `技能`:查看、上传、下载、删除技能包
- `插件`:查看已安装插件及其命令、技能、智能体
- `智能体`:新增和管理工作区智能体
- `MCP`:配置 MCP 服务、测试连接、查看发现的工具
- `市场`:浏览和安装市场中的插件
- `文件`:浏览工作区目录,上传、下载、删除文件/目录
- `帮助`:查看使用说明
## 页面路由
| 路由 | 说明 |
| --- | --- |
| `/` | 聊天工作台 |
| `/login` | 登录页 |
| `/register` | 注册页 |
| `/status` | 系统状态 |
| `/cron` | 定时任务 |
| `/skills` | 技能管理 |
| `/plugins` | 插件管理 |
| `/agents` | 智能体管理 |
| `/mcp` | MCP 服务管理 |
| `/marketplace` | 插件市场 |
| `/files` | 工作区文件管理 |
| `/help` | 帮助说明 |
## 技术栈
- Next.js 13.5
- React 18
- TypeScript
- Tailwind CSS
- Radix UI
- Zustand
- Lucide React
补充说明:
- 生产构建输出为 `standalone`
- 首页做了按需加载和请求链路优化
- 登录/注册与业务页已拆为不同 route group layout
## 目录结构
```text
app/
(app)/ 业务页面与业务布局
(auth)/ 登录/注册页面与认证布局
globals.css 全局样式
layout.tsx 根布局
components/
chat-workbench/ 聊天工作台组件
ui/ 通用 UI 组件
AuthGuard.tsx 认证守卫
Header.tsx 顶部导航
lib/
api.ts 前端 API / WebSocket 客户端
store.ts Zustand 状态管理
types/
index.ts 全局类型定义
```
## 本地开发
### 环境要求
- Node.js 20
- npm
- 可访问的后端服务
### 安装依赖
```bash
npm install
```
### 配置环境变量
可以参考 `env_template`
```env
NEXT_PUBLIC_API_URL=http://127.0.0.1:10000
NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000
NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081
```
当前前端的地址策略是:
- 如果配置了 `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL`,优先使用显式配置
- 如果配置了 `NEXT_PUBLIC_AUTH_PORTAL_URL`,未登录跳转会优先去独立 auth portal
- 如果未配置,浏览器端会优先使用当前站点同源地址
### 启动开发环境
```bash
npm run dev
```
默认监听:
- `http://0.0.0.0:3080`
## 构建与运行
### 本地生产构建
```bash
npm run build
npm run start
```
### 常用脚本
```bash
npm run dev
npm run build
npm run start
npm run lint
npm run typecheck
```
## Docker 运行
项目内已提供 `Dockerfile`,生产镜像基于 Next.js standalone 输出运行。
注意:
- 当前 `Dockerfile` 里包含 `npm run -s verify:modules` 校验步骤,但 `package.json` 里暂时没有这个脚本
- 如果你直接执行镜像构建,需要先补上该脚本,或者移除这一步校验
在修正上述校验步骤后,可按下面方式构建:
```bash
docker build -t boardware-genius-frontend .
docker run --rm -p 3000:3000 boardware-genius-frontend
```
如果你需要在构建时显式注入后端地址:
```bash
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
--build-arg NEXT_PUBLIC_WS_URL=wss://api.example.com \
-t boardware-genius-frontend .
```
## 部署建议
### 推荐:前后端同域部署
生产环境建议让前端页面与后端 API / WebSocket 走同一域名,例如:
- 前端页面:`https://boardware.example.com`
- API`https://boardware.example.com/api/...`
- WebSocket`wss://boardware.example.com/ws/...`
这样可以避免:
- 跨域预检
- 混合内容问题
- 证书域名不匹配
- 额外的浏览器安全限制
### 反向代理建议
推荐在 Nginx / Caddy / 网关层代理:
- `/api` -> 后端 HTTP 服务
- `/ws` -> 后端 WebSocket 服务
如果已经做了同域代理,前端可以不显式配置 `NEXT_PUBLIC_API_URL``NEXT_PUBLIC_WS_URL`
## 前端交互约定
### 认证
- 登录成功后,前端把 token 存在本地
- 业务页通过 `AuthGuard` 做访问控制
- 认证页与业务页已拆分布局,避免登录页加载业务导航
### 聊天
- 聊天页优先使用 WebSocket 实时通信
- 非关键数据采用延迟加载或空闲检查
- Markdown 渲染做了拆包,减少首页首包体积
### 状态管理
- 全局状态使用 Zustand
- 聊天消息、会话、连接状态、过程事件等都在 `lib/store.ts` 中维护
## 注意事项
### 1. 这是前端仓库
如果页面能打开但功能不可用,优先检查后端是否已启动并暴露:
- 认证接口
- 聊天接口
- 会话接口
- 状态接口
- WebSocket 连接
### 2. 命令名和目录名未做品牌迁移
当前仓库的部分技术标识仍沿用旧命名,例如:
- `nanobot web`
- `~/.nanobot/plugins/`
- 本地存储中的旧 token key
这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。
### 3. 动态内容可能仍包含英文
来自后端、插件、技能包或外部市场的数据描述,可能仍然带有英文内容。当前仓库已经把前端固定文案尽量中文化,但动态数据仍以实际返回为准。
## 维护建议
- 新增页面时优先放进合适的 route group`(app)``(auth)`
- 新增接口统一走 `lib/api.ts`
- 新增全局状态统一放到 `lib/store.ts`
- 新增用户可见文案优先使用中文,避免再次出现中英混杂
## 当前状态
- 页面品牌:`Boardware Genius`
- 中文化:已覆盖主要固定文案
- 布局拆分:已完成
- 生产构建:可通过 `npm run build`

View File

@ -0,0 +1,363 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react';
import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api';
import { useChatStore } from '@/lib/store';
import type { UiAgentDescriptor } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
const EMPTY_FORM = {
id: '',
name: '',
description: '',
base_url: '',
endpoint: '',
card_url: '',
auth_env: '',
auth_mode: 'none',
auth_audience: '',
auth_scopes: '',
tags: '',
aliases: '',
};
export default function AgentsPage() {
const cachedAgents = useChatStore((s) => s.agentRegistry);
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
const [loading, setLoading] = useState(cachedAgents.length === 0);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const load = useCallback(async (background = false) => {
if (background) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const data = await listAgents();
const nextAgents = Array.isArray(data) ? data : [];
setAgents(nextAgents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '加载智能体失败');
} finally {
if (background) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}, [setCachedAgents]);
useEffect(() => {
void load(cachedAgents.length > 0);
}, [cachedAgents.length, load]);
const handleRefresh = async () => {
setError(null);
setRefreshing(true);
try {
const data = await refreshAgents();
const nextAgents = data.agents || [];
setAgents(nextAgents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '刷新智能体失败');
} finally {
setRefreshing(false);
}
};
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
if (!open) {
setAdvancedOpen(false);
setForm(EMPTY_FORM);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim());
if (!hasAddress) {
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
return;
}
setSubmitting(true);
setError(null);
try {
await addAgent({
id: form.id || undefined,
name: form.name || undefined,
description: form.description || undefined,
protocol: 'a2a',
base_url: form.base_url || undefined,
endpoint: form.endpoint || undefined,
card_url: form.card_url || undefined,
auth_env: form.auth_env || undefined,
auth_mode: form.auth_mode || 'none',
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined,
auth_scopes: form.auth_mode === 'none'
? []
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean),
});
handleDialogOpenChange(false);
await load();
} catch (err: any) {
setError(err.message || '新增智能体失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (agentId: string) => {
try {
await deleteAgent(agentId);
await load();
} catch (err: any) {
setError(err.message || '删除智能体失败');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bot className="w-6 h-6" />
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleCreate}>
<div className="space-y-2">
<Label htmlFor="base_url">A2A </Label>
<Input
id="base_url"
value={form.base_url}
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))}
placeholder="https://agent.example.com 或 agent.example.com:19090"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
<code className="mx-1">/.well-known/agent-card</code>
<code className="mx-1">/.well-known/agent-card.json</code>
<code className="mx-1">/.well-known/agent.json</code>
ID
</p>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
<ChevronDown className={`w-4 h-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" />
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input id="name" value={form.name} onChange={(e) => setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea id="description" value={form.description} onChange={(e) => setForm((s) => ({ ...s, description: e.target.value }))} rows={3} placeholder="留空则从 A2A card 自动填充" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint"></Label>
<Input id="endpoint" value={form.endpoint} onChange={(e) => setForm((s) => ({ ...s, endpoint: e.target.value }))} placeholder="https://agent.example.com/rpc" />
</div>
<div className="space-y-2">
<Label htmlFor="card_url"></Label>
<Input id="card_url" value={form.card_url} onChange={(e) => setForm((s) => ({ ...s, card_url: e.target.value }))} placeholder="https://agent.example.com/.well-known/agent-card" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="auth_mode"></Label>
<select
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
<option value="oauth_backend_token">oauth_backend_token</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="auth_audience">Audience</Label>
<Input id="auth_audience" value={form.auth_audience} onChange={(e) => setForm((s) => ({ ...s, auth_audience: e.target.value }))} placeholder="planner 或 a2a:planner" />
</div>
<div className="space-y-2">
<Label htmlFor="auth_scopes">Scopes</Label>
<Input id="auth_scopes" value={form.auth_scopes} onChange={(e) => setForm((s) => ({ ...s, auth_scopes: e.target.value }))} placeholder="run_task" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="auth_env"></Label>
<Input id="auth_env" value={form.auth_env} onChange={(e) => setForm((s) => ({ ...s, auth_env: e.target.value }))} placeholder="例如MY_AGENT_TOKEN" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tags"></Label>
<Input id="tags" value={form.tags} onChange={(e) => setForm((s) => ({ ...s, tags: e.target.value }))} placeholder="例如:评审, 代码, 安全" />
</div>
<div className="space-y-2">
<Label htmlFor="aliases"></Label>
<Input id="aliases" value={form.aliases} onChange={(e) => setForm((s) => ({ ...s, aliases: e.target.value }))} placeholder="例如reviewer, audit-agent" />
</div>
</div>
</CollapsibleContent>
</Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
card
<code className="mx-1">.well-known</code>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{agents.map((agent) => {
const isWorkspace = agent.source === 'workspace';
return (
<Card key={agent.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{agent.description || '—'}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>}
{agent.support_group && <Badge className="bg-emerald-600"></Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>}
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>}
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>}
</div>
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
<div className="space-y-2">
{agent.tags.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
{agent.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
{agent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
{agent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
</div>
)}
</div>
)}
<div className="flex justify-end">
{isWorkspace ? (
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,400 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Clock,
Plus,
Trash2,
Play,
RefreshCw,
Loader2,
AlertCircle,
X,
} from 'lucide-react';
import {
listCronJobs,
addCronJob,
removeCronJob,
toggleCronJob,
runCronJob,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CronJob } from '@/types';
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const loadJobs = async () => {
setLoading(true);
setError(null);
try {
const data = await listCronJobs(true);
setJobs(data);
} catch (err: any) {
setError(err.message || '加载任务失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadJobs();
}, []);
const handleToggle = async (jobId: string, enabled: boolean) => {
try {
await toggleCronJob(jobId, enabled);
loadJobs();
} catch {
// ignore
}
};
const handleDelete = async (jobId: string) => {
try {
await removeCronJob(jobId);
loadJobs();
} catch {
// ignore
}
};
const handleRun = async (jobId: string) => {
try {
await runCronJob(jobId);
loadJobs();
} catch {
// ignore
}
};
const handleAdd = async (params: {
name: string;
message: string;
every_seconds?: number;
cron_expr?: string;
}) => {
try {
await addCronJob(params);
setShowAdd(false);
loadJobs();
} catch (err: any) {
setError(err.message);
}
};
const formatTime = (ms: number | null) => {
if (!ms) return '-';
return new Date(ms).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Clock className="w-6 h-6" />
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadJobs} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Add Job Form */}
{showAdd && (
<AddJobForm
onAdd={handleAdd}
onCancel={() => setShowAdd(false)}
/>
)}
{/* Jobs Table */}
<Card>
<CardContent className="p-0">
{jobs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch
checked={job.enabled}
onCheckedChange={(checked) =>
handleToggle(job.id, checked)
}
/>
</TableCell>
<TableCell className="font-medium">
<div>
<span>{job.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{job.id}
</span>
</div>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{job.schedule_display}
</code>
</TableCell>
<TableCell>
<span className="text-sm truncate max-w-[200px] block">
{job.message}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(job.last_run_at_ms)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(job.next_run_at_ms)}
</TableCell>
<TableCell>
{job.last_status === 'ok' && (
<Badge variant="default" className="text-xs bg-green-600">
OK
</Badge>
)}
{job.last_status === 'error' && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
{!job.last_status && (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleRun(job.id)}
title="立即执行"
>
<Play className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(job.id)}
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function AddJobForm({
onAdd,
onCancel,
}: {
onAdd: (params: {
name: string;
message: string;
every_seconds?: number;
cron_expr?: string;
}) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
const [everySeconds, setEverySeconds] = useState('3600');
const [cronExpr, setCronExpr] = useState('0 9 * * *');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !message.trim()) return;
const params: any = { name: name.trim(), message: message.trim() };
if (scheduleType === 'every') {
params.every_seconds = parseInt(everySeconds, 10) || 3600;
} else {
params.cron_expr = cronExpr.trim();
}
onAdd(params);
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:日报汇总"
/>
</div>
<div className="space-y-2">
<Label htmlFor="schedule-type"></Label>
<Select
value={scheduleType}
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="every"> N </SelectItem>
<SelectItem value="cron">Cron </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scheduleType === 'every' ? (
<div className="space-y-2">
<Label htmlFor="every"></Label>
<Input
id="every"
type="number"
value={everySeconds}
onChange={(e) => setEverySeconds(e.target.value)}
min="10"
placeholder="3600"
/>
<p className="text-xs text-muted-foreground">
{parseInt(everySeconds, 10) >= 3600
? `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}`
: parseInt(everySeconds, 10) >= 60
? `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}`
: ''}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="cron">Cron </Label>
<Input
id="cron"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
placeholder="0 9 * * *"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="message"></Label>
<Input
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="例如:检查我的邮件并生成摘要"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,376 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Trash2,
Download,
Loader2,
FolderOpen,
Folder,
FileText,
Upload,
FolderPlus,
ChevronRight,
Home,
RefreshCw,
FileImage,
FileCode,
FileArchive,
FileSpreadsheet,
} from 'lucide-react';
import {
browseWorkspace,
getWorkspaceDownloadUrl,
uploadToWorkspace,
deleteWorkspacePath,
createWorkspaceDir,
getAccessToken,
} from '@/lib/api';
import type { WorkspaceItem } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function FilesPage() {
const [items, setItems] = useState<WorkspaceItem[]>([]);
const [currentPath, setCurrentPath] = useState('');
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [showMkdir, setShowMkdir] = useState(false);
const [newDirName, setNewDirName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const mkdirInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (path: string = currentPath) => {
try {
setLoading(true);
const data = await browseWorkspace(path);
setItems(data.items);
setCurrentPath(data.path);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [currentPath]);
useEffect(() => {
load('');
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (path: string) => {
load(path);
};
const handleDelete = async (item: WorkspaceItem) => {
const label = item.type === 'directory' ? '文件夹' : '文件';
if (!confirm(`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件)' : ''}`)) {
return;
}
try {
await deleteWorkspacePath(item.path);
setItems((prev) => prev.filter((i) => i.path !== item.path));
} catch {
// ignore
}
};
const handleDownload = async (item: WorkspaceItem) => {
const url = getWorkspaceDownloadUrl(item.path);
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const res = await fetch(url, { headers });
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch {
// ignore
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadToWorkspace(files[i], currentPath, (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
await load();
} catch {
// ignore
} finally {
setUploading(false);
setUploadProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleCreateDir = async () => {
const name = newDirName.trim();
if (!name) return;
try {
const dirPath = currentPath ? `${currentPath}/${name}` : name;
await createWorkspaceDir(dirPath);
setShowMkdir(false);
setNewDirName('');
await load();
} catch {
// ignore
}
};
// Build breadcrumbs
const breadcrumbs = currentPath ? currentPath.split('/') : [];
const formatSize = (bytes: number | null) => {
if (bytes === null || bytes === undefined) return '';
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
};
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '';
}
};
return (
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold"></h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowMkdir(true)}
disabled={loading}
>
<FolderPlus className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{uploadProgress}%
</>
) : (
<>
<Upload className="w-4 h-4 mr-1" />
</>
)}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</Button>
</div>
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
<button
onClick={() => navigateTo('')}
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
>
<Home className="w-3.5 h-3.5" />
</button>
{breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/');
const isLast = idx === breadcrumbs.length - 1;
return (
<React.Fragment key={path}>
<ChevronRight className="w-3 h-3 flex-shrink-0" />
<button
onClick={() => navigateTo(path)}
className={`px-1.5 py-0.5 rounded transition-colors ${
isLast
? 'text-foreground font-medium'
: 'hover:text-foreground hover:bg-accent'
}`}
>
{segment}
</button>
</React.Fragment>
);
})}
</div>
{/* New directory input */}
{showMkdir && (
<div className="flex items-center gap-2 mb-4">
<Folder className="w-4 h-4 text-muted-foreground" />
<input
ref={mkdirInputRef}
type="text"
value={newDirName}
onChange={(e) => setNewDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateDir();
if (e.key === 'Escape') {
setShowMkdir(false);
setNewDirName('');
}
}}
placeholder="文件夹名称"
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<Button size="sm" onClick={handleCreateDir}>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowMkdir(false);
setNewDirName('');
}}
>
</Button>
</div>
)}
{/* File list */}
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium"></p>
<p className="text-sm">"上传""新建文件夹"使</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-14rem)]">
<div className="space-y-1">
{items.map((item) => (
<div
key={item.path}
className="flex items-center gap-3 px-4 py-2.5 rounded-lg border border-border bg-card hover:bg-accent/30 transition-colors group"
>
{/* Icon */}
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type} />
)}
</div>
{/* Name - clickable for directories */}
<div className="flex-1 min-w-0">
{item.type === 'directory' ? (
<button
onClick={() => navigateTo(item.path)}
className="text-sm font-medium truncate hover:underline text-left block w-full"
>
{item.name}
</button>
) : (
<p className="text-sm font-medium truncate">{item.name}</p>
)}
<p className="text-xs text-muted-foreground">
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{item.type === 'file' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleDownload(item)}
title="下载"
>
<Download className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(item)}
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</div>
);
}
function FileIcon({ name, contentType }: { name: string; contentType?: string }) {
const ext = name.split('.').pop()?.toLowerCase() || '';
const ct = contentType || '';
if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) {
return <FileImage className="w-5 h-5 text-green-500" />;
}
if (
['js', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'md', 'sh'].includes(ext)
) {
return <FileCode className="w-5 h-5 text-orange-500" />;
}
if (['zip', 'tar', 'gz', 'bz2', 'rar', '7z', 'xz'].includes(ext)) {
return <FileArchive className="w-5 h-5 text-yellow-500" />;
}
if (['csv', 'xls', 'xlsx', 'tsv'].includes(ext)) {
return <FileSpreadsheet className="w-5 h-5 text-emerald-500" />;
}
return <FileText className="w-5 h-5 text-muted-foreground" />;
}

View File

@ -0,0 +1,168 @@
'use client';
import React, { useState } from 'react';
import {
MessageSquare,
Terminal,
Layers,
Wifi,
WifiOff,
Plus,
Trash2,
Send,
ChevronDown,
ChevronRight,
} from 'lucide-react';
interface SectionProps {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
function Section({ icon, title, children, defaultOpen = false }: SectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
className="w-full flex items-center gap-3 px-4 py-3 bg-card hover:bg-accent/50 transition-colors text-left"
onClick={() => setOpen((v) => !v)}
>
<span className="text-primary">{icon}</span>
<span className="font-medium flex-1">{title}</span>
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{open && (
<div className="px-4 py-4 space-y-3 text-sm text-muted-foreground border-t border-border bg-background">
{children}
</div>
)}
</div>
);
}
function Tag({ children, color = 'default' }: { children: React.ReactNode; color?: 'green' | 'yellow' | 'red' | 'default' }) {
const cls = {
green: 'bg-green-900/30 text-green-400 border-green-800',
yellow: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
red: 'bg-red-900/30 text-red-400 border-red-800',
default: 'bg-muted text-foreground border-border',
}[color];
return (
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-mono ${cls}`}>
{children}
</span>
);
}
export default function HelpPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-8 space-y-4">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-1">使</h1>
<p className="text-muted-foreground text-sm">使 Boardware Genius </p>
</div>
<Section icon={<MessageSquare className="w-5 h-5" />} title="如何开始对话" defaultOpen>
<p><strong className="text-foreground"></strong><strong className="text-foreground"></strong></p>
<ol className="list-decimal list-inside space-y-1.5 ml-1">
<li></li>
<li> <Tag>Enter</Tag> <Tag>Shift + Enter</Tag> </li>
<li> Boardware Genius "思考中..."</li>
</ol>
<p className="mt-1">
<Tag></Tag>
</p>
</Section>
<Section icon={<Terminal className="w-5 h-5" />} title="斜杠命令(/命令)">
<p> <Tag>/</Tag> </p>
<ul className="space-y-1.5 ml-1">
<li> <Tag>/</Tag> </li>
<li> <Tag></Tag> <Tag></Tag> </li>
<li> <Tag>Enter</Tag> <Tag>Tab</Tag> </li>
<li> <Tag>Esc</Tag> </li>
</ul>
<p><strong className="text-foreground"></strong><strong className="text-foreground"></strong></p>
</Section>
<Section icon={<Layers className="w-5 h-5" />} title="对话管理">
<ul className="space-y-2 ml-1">
<li>
<span className="inline-flex items-center gap-1"><Plus className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
<li>
<span className="inline-flex items-center gap-1"><MessageSquare className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
<li>
<span className="inline-flex items-center gap-1"><Trash2 className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
</ul>
<p className="text-xs mt-1 text-muted-foreground/70"></p>
</Section>
<Section icon={<Wifi className="w-5 h-5" />} title="连接状态说明">
<p></p>
<div className="space-y-2 ml-1 mt-2">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Tag color="green"></Tag>
<span> Boardware Genius </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
<Tag color="yellow"> / </Tag>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
<Tag color="red">线</Tag>
<span> Boardware Genius </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
<Tag color="red"></Tag>
<span> </span>
</div>
</div>
<p className="mt-3 text-xs">
<strong className="text-foreground"></strong>"服务离线"
</p>
</Section>
<Section icon={<Send className="w-5 h-5" />} title="输入技巧">
<ul className="space-y-2 ml-1">
<li><Tag>Enter</Tag> </li>
<li><Tag>Shift + Enter</Tag> </li>
<li>使 Enter Enter </li>
<li> <Tag>/</Tag> </li>
</ul>
</Section>
<Section icon={<WifiOff className="w-5 h-5" />} title="常见问题">
<div className="space-y-4">
<div>
<p className="font-medium text-foreground mb-1">Q</p>
<p>"已连接""服务离线""未连接"</p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q Boardware Genius </p>
<p><strong className="text-foreground"></strong>AI </p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q使</p>
<p><strong className="text-foreground"></strong></p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q</p>
<p><strong className="text-foreground"></strong> Boardware Genius <strong className="text-foreground"></strong> Agent </p>
</div>
</div>
</Section>
</div>
);
}

View File

@ -0,0 +1,17 @@
import Header from '@/components/Header';
import AuthGuard from '@/components/AuthGuard';
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-background text-foreground">
<Header />
<main className="pt-16">
<AuthGuard>{children}</AuthGuard>
</main>
</div>
);
}

View File

@ -0,0 +1,437 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import {
Store,
RefreshCw,
Loader2,
AlertCircle,
Plus,
Trash2,
Download,
Check,
X,
Globe,
FolderOpen,
} from 'lucide-react';
import {
listMarketplaces,
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplacePlugins,
installMarketplacePlugin,
uninstallPlugin,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { Marketplace, MarketplacePlugin } from '@/types';
export default function MarketplacePage() {
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
const [loading, setLoading] = useState(true);
const [pluginsLoading, setPluginsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [addSource, setAddSource] = useState('');
const [adding, setAdding] = useState(false);
const [actionPlugin, setActionPlugin] = useState<string | null>(null);
const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null);
const loadMarketplaces = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listMarketplaces();
const list = Array.isArray(data) ? data : [];
setMarketplaces(list);
// Auto-select first marketplace if none selected or selected was removed
if (list.length > 0) {
setSelectedMarketplace((prev) => {
if (prev && list.some((m) => m.name === prev)) return prev;
return list[0].name;
});
} else {
setSelectedMarketplace(null);
setPlugins([]);
}
} catch (err: any) {
setError(err.message || '加载市场失败');
} finally {
setLoading(false);
}
}, []);
const loadPlugins = useCallback(async (marketplaceName: string) => {
setPluginsLoading(true);
try {
const data = await listMarketplacePlugins(marketplaceName);
setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载插件失败');
} finally {
setPluginsLoading(false);
}
}, []);
useEffect(() => {
loadMarketplaces();
}, [loadMarketplaces]);
useEffect(() => {
if (selectedMarketplace) {
loadPlugins(selectedMarketplace);
}
}, [selectedMarketplace, loadPlugins]);
const handleAdd = async () => {
if (!addSource.trim()) return;
setAdding(true);
setError(null);
try {
const marketplace = await addMarketplace(addSource.trim());
setAddSource('');
setShowAddForm(false);
await loadMarketplaces();
setSelectedMarketplace(marketplace.name);
} catch (err: any) {
setError(err.message || '添加市场失败');
} finally {
setAdding(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
try {
await removeMarketplace(name);
if (selectedMarketplace === name) {
setSelectedMarketplace(null);
setPlugins([]);
}
await loadMarketplaces();
} catch (err: any) {
setError(err.message || '移除市场失败');
}
};
const handleUpdateMarketplace = async (name: string) => {
setUpdatingMarketplace(name);
setError(null);
try {
await updateMarketplace(name);
await loadPlugins(name);
} catch (err: any) {
setError(err.message || '更新市场失败');
} finally {
setUpdatingMarketplace(null);
}
};
const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => {
setActionPlugin(pluginName);
setError(null);
try {
await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName);
} catch (err: any) {
setError(err.message || '更新插件失败');
} finally {
setActionPlugin(null);
}
};
const handleInstall = async (marketplaceName: string, pluginName: string) => {
setActionPlugin(pluginName);
setError(null);
try {
await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName);
} catch (err: any) {
setError(err.message || '安装插件失败');
} finally {
setActionPlugin(null);
}
};
const handleUninstall = async (pluginName: string) => {
setActionPlugin(pluginName);
setError(null);
try {
await uninstallPlugin(pluginName);
if (selectedMarketplace) {
await loadPlugins(selectedMarketplace);
}
} catch (err: any) {
setError(err.message || '卸载插件失败');
} finally {
setActionPlugin(null);
}
};
const handleRefresh = async () => {
await loadMarketplaces();
if (selectedMarketplace) {
await loadPlugins(selectedMarketplace);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Store className="w-6 h-6" />
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setShowAddForm((v) => !v)}
variant="outline"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* Error */}
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center justify-between gap-2 text-destructive text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0 h-6 w-6 p-0"
onClick={() => setError(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Add marketplace form */}
{showAddForm && (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Input
placeholder="本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/..."
value={addSource}
onChange={(e) => setAddSource(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
}}
disabled={adding}
className="flex-1"
/>
<Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm">
{adding ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
</Button>
<Button
onClick={() => {
setShowAddForm(false);
setAddSource('');
}}
variant="ghost"
size="sm"
>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Marketplace tabs */}
{marketplaces.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{marketplaces.map((marketplace) => (
<div key={marketplace.name} className="flex items-center gap-0.5">
<Button
variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMarketplace(marketplace.name)}
className="gap-1.5"
>
{marketplace.type === 'git' ? (
<Globe className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5" />
)}
{marketplace.name}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
disabled={updatingMarketplace === marketplace.name}
onClick={() => handleUpdateMarketplace(marketplace.name)}
title="更新市场"
>
{updatingMarketplace === marketplace.name ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(marketplace.name)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
{/* Empty state */}
{marketplaces.length === 0 && !error && (
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-2 max-w-sm mx-auto">
<strong></strong> Git 使
</p>
</CardContent>
</Card>
)}
{/* Plugin list */}
{selectedMarketplace && (
<>
{pluginsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : plugins.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"></p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{plugins.map((plugin) => (
<Card key={plugin.name}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base font-semibold">
{plugin.name}
</CardTitle>
{plugin.installed && (
<Badge variant="secondary" className="text-xs gap-1">
<Check className="w-3 h-3" />
</Badge>
)}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{plugin.description}
</p>
)}
</div>
<div className="shrink-0 flex items-center gap-2">
{plugin.installed ? (
<>
<Button
variant="outline"
size="sm"
disabled={actionPlugin === plugin.name}
onClick={() =>
handleUpdatePlugin(plugin.marketplace_name, plugin.name)
}
>
{actionPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
</Button>
<Button
variant="outline"
size="sm"
disabled={actionPlugin === plugin.name}
onClick={() => handleUninstall(plugin.name)}
>
{actionPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
</Button>
</>
) : (
<Button
variant="default"
size="sm"
disabled={actionPlugin === plugin.name}
onClick={() =>
handleInstall(plugin.marketplace_name, plugin.name)
}
>
{actionPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
</Button>
)}
</div>
</div>
</CardHeader>
</Card>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,589 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { AlertCircle, Loader2, Plus, RefreshCw, ServerCog, TestTube2, Trash2, Wrench } from 'lucide-react';
import { addMcpServer, deleteMcpServer, getAuthzStatus, listMcpServers, listMcpTools, testMcpServer, updateMcpServer } from '@/lib/api';
import { useChatStore } from '@/lib/store';
import { cn } from '@/lib/utils';
import type { AuthzStatus, UiMcpServerDescriptor } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
type McpFormMode = 'remote' | 'install';
const EMPTY_FORM = {
mode: 'remote' as McpFormMode,
id: '',
command: '',
args: '',
url: '',
headers: '{}',
auth_mode: 'none',
tool_timeout: '30',
};
function createEmptyForm() {
return { ...EMPTY_FORM };
}
function resolveFormMode(server?: UiMcpServerDescriptor): McpFormMode {
if (server?.transport === 'stdio') return 'install';
if (server?.transport === 'http') return 'remote';
if (server?.url) return 'remote';
return 'install';
}
function resolveAuthAudience(serverId: string) {
const trimmed = serverId.trim();
return trimmed ? `mcp:${trimmed}` : '';
}
function resolveAuthzMcpScopes(authzStatus: AuthzStatus | null, serverId: string): { available: boolean; enabled: boolean; scopes: string[] } {
const trimmed = serverId.trim();
if (!trimmed) {
return { available: false, enabled: false, scopes: [] };
}
const permissions = authzStatus?.permissions;
if (!permissions || typeof permissions !== 'object') {
return { available: false, enabled: false, scopes: [] };
}
const mcpPermissions = (permissions as Record<string, unknown>).mcp;
if (!mcpPermissions || typeof mcpPermissions !== 'object') {
return { available: false, enabled: false, scopes: [] };
}
const serverPermission = (mcpPermissions as Record<string, unknown>)[trimmed];
if (!serverPermission || typeof serverPermission !== 'object') {
return { available: false, enabled: false, scopes: [] };
}
const enabled = Boolean((serverPermission as Record<string, unknown>).enabled);
const rawTools: unknown[] = Array.isArray((serverPermission as Record<string, unknown>).tools)
? ((serverPermission as Record<string, unknown>).tools as unknown[])
: [];
const toolScopes = rawTools
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool.trim().length > 0)
.map((tool) => `tool:${tool.trim()}`);
return {
available: true,
enabled,
scopes: enabled ? ['list_tools', ...toolScopes] : [],
};
}
function serverStatusLabel(status?: string | null) {
if (status === 'connected') return '已连接';
if (status === 'error') return '异常';
if (status === 'disconnected' || !status) return '未连接';
return status;
}
function transportLabel(transport?: string) {
if (transport === 'stdio') return '标准输入输出';
if (transport === 'http') return 'HTTP';
return transport || '-';
}
export default function MCPPage() {
const cachedServers = useChatStore((s) => s.mcpRegistry);
const cachedTools = useChatStore((s) => s.mcpToolRegistry);
const setCachedServers = useChatStore((s) => s.setMcpRegistry);
const setCachedTools = useChatStore((s) => s.setMcpToolRegistry);
const [servers, setServers] = useState<UiMcpServerDescriptor[]>(cachedServers);
const [tools, setTools] = useState<Array<{ server_id: string; tools: Array<Record<string, unknown>> }>>(cachedTools);
const [loading, setLoading] = useState(cachedServers.length === 0 && cachedTools.length === 0);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(createEmptyForm());
const [authzStatus, setAuthzStatus] = useState<AuthzStatus | null>(null);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const load = useCallback(async (background = false) => {
if (background) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const [serverData, toolData, authzData] = await Promise.all([
listMcpServers(),
listMcpTools(),
getAuthzStatus().catch(() => null),
]);
const nextServers = Array.isArray(serverData) ? serverData : [];
const nextTools = Array.isArray(toolData) ? toolData : [];
setServers(nextServers);
setTools(nextTools);
setCachedServers(nextServers);
setCachedTools(nextTools);
setAuthzStatus(authzData);
setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null));
} catch (err: any) {
setError(err.message || '加载 MCP 服务失败');
} finally {
if (background) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}, [setCachedServers, setCachedTools]);
useEffect(() => {
void load(cachedServers.length > 0 || cachedTools.length > 0);
}, [cachedServers.length, cachedTools.length, load]);
const resetForm = () => {
setEditingId(null);
setForm(createEmptyForm());
};
const openEdit = (server: UiMcpServerDescriptor) => {
setEditingId(server.id);
setForm({
mode: resolveFormMode(server),
id: server.id,
command: server.command || '',
args: (server.args || []).join(' '),
url: server.url || '',
headers: JSON.stringify(server.headers || {}, null, 2),
auth_mode: server.auth_mode || 'none',
tool_timeout: String(server.tool_timeout || 30),
});
setDialogOpen(true);
};
const parseObjectField = (label: string, value: string) => {
if (!value.trim()) return {};
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
return parsed as Record<string, string>;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const id = form.id.trim();
const url = form.url.trim();
const command = form.command.trim();
const toolTimeout = Number(form.tool_timeout || 30);
if (!id) {
throw new Error('ID 不能为空');
}
if (!Number.isFinite(toolTimeout) || toolTimeout < 1) {
throw new Error('工具超时必须大于 0');
}
if (form.mode === 'remote' && !url) {
throw new Error('请输入 MCP Server 地址');
}
if (form.mode === 'install' && !command) {
throw new Error('请输入安装或启动命令');
}
const authMode = form.mode === 'remote' ? (form.auth_mode || 'none') : 'none';
const authAudience = authMode === 'oauth_backend_token' ? resolveAuthAudience(id) : '';
const payload = {
id,
command: form.mode === 'install' ? command : '',
args: form.mode === 'install'
? form.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
: [],
env: {},
url: form.mode === 'remote' ? url : '',
headers: form.mode === 'remote' ? parseObjectField('请求头', form.headers) : {},
auth_mode: authMode,
auth_audience: authAudience,
auth_scopes: [],
tool_timeout: toolTimeout,
};
if (editingId) {
await updateMcpServer(editingId, payload);
} else {
await addMcpServer(payload);
}
setDialogOpen(false);
resetForm();
await load();
} catch (err: any) {
setError(err.message || '保存 MCP 服务失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (serverId: string) => {
try {
await deleteMcpServer(serverId);
setSelectedServerId((current) => (current === serverId ? null : current));
await load();
} catch (err: any) {
setError(err.message || '删除 MCP 服务失败');
}
};
const handleTest = async (serverId: string) => {
setTestingId(serverId);
try {
await testMcpServer(serverId);
await load(true);
} catch (err: any) {
setError(err.message || '测试 MCP 服务失败');
} finally {
setTestingId(null);
}
};
const authAudience = resolveAuthAudience(form.id);
const authzMcpScopes = resolveAuthzMcpScopes(authzStatus, form.id);
const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null;
const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null;
let authzHint = '无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。';
if (showAuthzPreview) {
if (!form.id.trim()) {
authzHint = '先填写 MCP IDAudience 会自动生成为 mcp:<id>。';
} else if (!authzStatus?.enabled) {
authzHint = '当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。';
} else if (!authzStatus.local_backend.registered) {
authzHint = '当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。';
} else if (authzStatus.error) {
authzHint = `读取 AuthZ 权限失败:${authzStatus.error}`;
} else if (!authzMcpScopes.available || !authzMcpScopes.enabled) {
authzHint = `AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`;
} else {
authzHint = `已从 AuthZ 读取到 ${authAudience} 的当前权限。`;
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ServerCog className="w-6 h-6" />
MCP
</h1>
<p className="text-sm text-muted-foreground mt-1">
MCP
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load(true)}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
MCP
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? '编辑 MCP 服务' : '新增 MCP 服务'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
</div>
<div className="space-y-2">
<Label htmlFor="tool_timeout"></Label>
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
</div>
</div>
<Tabs
value={form.mode}
onValueChange={(value) => setForm((s) => ({ ...s, mode: value as McpFormMode }))}
className="space-y-4"
>
<div className="space-y-2">
<Label></Label>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 bg-transparent p-0 sm:grid-cols-2">
<TabsTrigger
value="remote"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
>
<span className="text-sm font-medium"> MCP Server</span>
<span className="text-xs font-normal text-muted-foreground">
MCP URL
</span>
</TabsTrigger>
<TabsTrigger
value="install"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
>
<span className="text-sm font-medium"></span>
<span className="text-xs font-normal text-muted-foreground">
`npx``uvx` MCP
</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="remote" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground">
MCP Server访
</div>
<div className="space-y-2">
<Label htmlFor="url">MCP Server </Label>
<Input
id="url"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
placeholder="http://localhost:3001/mcp"
required={form.mode === 'remote'}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="auth_mode"></Label>
<select
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
<option value="oauth_backend_token">oauth_backend_token</option>
</select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>AuthZ </Label>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-3 text-sm space-y-2">
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Audience</span>
<span className="font-mono text-xs break-all">
{showAuthzPreview ? (authAudience || '填写 MCP ID 后自动生成') : '关闭鉴权时无需配置'}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Scopes</span>
<span className="text-xs break-words">
{showAuthzPreview
? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : '由 AuthZ 当前权限动态决定')
: '关闭鉴权时无需配置'}
</span>
</div>
<div className="text-xs text-muted-foreground">
{authzHint}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="headers"> JSON</Label>
<Textarea
id="headers"
rows={8}
value={form.headers}
onChange={(e) => setForm((s) => ({ ...s, headers: e.target.value }))}
/>
</div>
</TabsContent>
<TabsContent value="install" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground">
MCP `npx``uvx`
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="command"></Label>
<Input
id="command"
value={form.command}
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
required={form.mode === 'install'}
/>
</div>
<div className="space-y-2">
<Label htmlFor="args"></Label>
<Input
id="args"
value={form.args}
onChange={(e) => setForm((s) => ({ ...s, args: e.target.value }))}
placeholder="-y @modelcontextprotocol/server-github"
/>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] gap-4">
<div className="space-y-4">
{servers.map((server) => (
<Card
key={server.id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedServerId(server.id);
}
}}
className={cn(
'cursor-pointer transition-colors',
selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">{server.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{transportLabel(server.transport)}</Badge>
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status)}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3 text-sm">
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
{server.command && <div><span className="font-medium"></span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium"></span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div><span className="font-medium">Audience</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)}
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground"> AuthZ </span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{server.tool_count || 0} </span>
<span>{selectedServerId === server.id ? '已选中' : '点击查看工具'}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
openEdit(server);
}}>
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleTest(server.id);
}} disabled={testingId === server.id}>
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleDelete(server.id);
}}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
))}
{servers.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
MCP
</CardContent>
</Card>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
{selectedServer ? `${selectedServer.name} 的工具` : 'MCP 工具'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selectedServer && (
<div className="py-10 text-sm text-muted-foreground text-center">
MCP
</div>
)}
{selectedServer && !selectedToolGroup && (
<div className="text-sm text-muted-foreground"> MCP </div>
)}
{selectedToolGroup && (
<div className="space-y-2">
<div className="text-sm font-medium">{selectedToolGroup.server_id}</div>
<div className="space-y-2">
{selectedToolGroup.tools.map((tool) => (
<div key={String(tool.name)} className="rounded-md border border-border/70 px-3 py-2 bg-background/60">
<div className="text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
<div className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words">
{String(tool.description || '—')}
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,637 @@
'use client';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
cancelDelegation,
deleteSession,
getSession,
getStatus,
listCommands,
listSessions,
sendMessage,
uploadFile,
wsManager,
} from '@/lib/api';
import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SlashCommand, WsEvent } from '@/types';
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
if (typeof window === 'undefined') {
task();
return () => {};
}
const idleWindow = window as Window &
typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof idleWindow.requestIdleCallback === 'function') {
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(task, 250);
return () => globalThis.clearTimeout(id);
}
function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? [])
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
.join('|');
return `${msg.role}::${String(msg.content)}::${attachmentKey}`;
}
function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] {
const counts = new Map<string, number>();
for (const message of serverMessages) {
const key = messageFingerprint(message);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const pendingUsers: ChatMessage[] = [];
for (const message of localMessages) {
const key = messageFingerprint(message);
const count = counts.get(key) ?? 0;
if (count > 0) {
counts.set(key, count - 1);
continue;
}
if (message.role === 'user') {
pendingUsers.push(message);
}
}
return [...serverMessages, ...pendingUsers];
}
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
const type = typeof data.type === 'string' ? data.type : '';
return type.startsWith('process_') || type === 'process_cancel_ack';
}
export default function ChatPage() {
const {
sessionId,
messages,
isLoading,
isThinking,
sessions,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
setSessionId,
setMessages,
addMessage,
setIsLoading,
setSessions,
clearMessages,
setWsStatus,
setIsThinking,
setNanobotReady,
resetProcessState,
ingestProcessEvent,
setSelectedRunId,
} = useChatStore();
const [input, setInput] = useState('');
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [showCommandPicker, setShowCommandPicker] = useState(false);
const [pickerIndex, setPickerIndex] = useState(0);
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
const commandsLoadedRef = useRef(false);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
const statusCheckInFlightRef = useRef(false);
const shouldSnapToLatestRef = useRef(true);
const filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return [];
const filter = input.slice(1).toLowerCase();
return commands.filter(
(command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter))
);
}, [commands, input]);
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
setSessions(list);
} catch {
// backend may be offline during first render
}
}, [setSessions]);
const loadSessionMessages = useCallback(async (key: string) => {
const reqSeq = ++loadSessionReqSeq.current;
const localSnapshot = useChatStore.getState().messages;
const waitingForReply = useChatStore.getState().isLoading || useChatStore.getState().isThinking;
try {
const detail = await getSession(key);
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
const nextMessages = waitingForReply
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
setMessages(nextMessages);
const last = nextMessages[nextMessages.length - 1];
if (last?.role === 'assistant') {
setIsThinking(false);
setIsLoading(false);
}
} catch {
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
}
}, [setIsLoading, setIsThinking, setMessages]);
const loadCommands = useCallback(async () => {
if (commandsLoadedRef.current) return;
commandsLoadedRef.current = true;
try {
const nextCommands = await listCommands();
setCommands(nextCommands);
} catch {
commandsLoadedRef.current = false;
}
}, []);
const scheduleStatusCheck = useCallback(() => {
if (statusCheckInFlightRef.current) return;
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
} catch {
setNanobotReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
useEffect(() => {
if (input.startsWith('/') && !input.includes(' ')) {
void loadCommands();
}
}, [input, loadCommands]);
useEffect(() => {
setShowCommandPicker(filteredCommands.length > 0);
setPickerIndex(0);
}, [filteredCommands]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => {
resetProcessState();
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
wsManager.connect(wsSessionId);
loadSessionMessages(sessionId);
}, [loadSessionMessages, resetProcessState, sessionId]);
useEffect(() => {
const unsubStatus = wsManager.onStatusChange(async (status) => {
setWsStatus(status);
if (status === 'connected') {
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
hasConnectedRef.current = true;
scheduleStatusCheck();
} else {
if (status === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
}
});
const unsubMessage = wsManager.onMessage((data) => {
if (isProcessEvent(data)) {
ingestProcessEvent(data);
return;
}
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
setIsThinking(false);
setIsLoading(false);
addMessage({
role: 'assistant',
content: typeof data.content === 'string' ? data.content : '',
timestamp: new Date().toISOString(),
attachments: Array.isArray(data.attachments) ? data.attachments : undefined,
});
loadSessions();
}
});
return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
unsubMessage();
};
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
useEffect(() => {
if (!isLoading && !isThinking) {
return;
}
const timer = setInterval(() => {
loadSessionMessages(useChatStore.getState().sessionId);
}, 1500);
return () => clearInterval(timer);
}, [isLoading, isThinking, loadSessionMessages]);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;
if (!viewport) return;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
}, []);
useEffect(() => {
shouldSnapToLatestRef.current = true;
}, [sessionId]);
useLayoutEffect(() => {
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
return;
}
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
shouldSnapToLatestRef.current = false;
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return;
const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}, [pickerIndex, showCommandPicker]);
const selectCommand = useCallback((command: SlashCommand) => {
setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`);
setShowCommandPicker(false);
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const text = input.trim();
if ((!text && pendingFiles.length === 0) || isLoading) return;
const readyFiles = pendingFiles.filter((p) => p.id && !p.error);
const attachments: FileAttachment[] = readyFiles.map((item) => ({
file_id: item.id!,
name: item.file.name,
content_type: item.file.type || 'application/octet-stream',
size: item.file.size,
}));
setInput('');
setPendingFiles([]);
setShowCommandPicker(false);
const msgContent = text || '(仅附件)';
addMessage({
role: 'user',
content: msgContent,
timestamp: new Date().toISOString(),
attachments: attachments.length > 0 ? attachments : undefined,
});
setIsLoading(true);
setIsThinking(false);
if (wsManager.getStatus() === 'connected') {
const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent };
if (attachments.length > 0) {
wsPayload.attachments = attachments;
}
wsManager.sendRaw(wsPayload);
} else {
try {
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined);
setIsThinking(false);
setIsLoading(false);
if (result.response) {
addMessage({
role: 'assistant',
content: result.response,
timestamp: new Date().toISOString(),
});
loadSessions();
} else {
await loadSessionMessages(sessionId);
loadSessions();
}
} catch {
setIsThinking(false);
setIsLoading(false);
addMessage({
role: 'assistant',
content: '发送失败,请检查后端服务是否正在运行。',
timestamp: new Date().toISOString(),
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
if (e.key === 'ArrowUp') {
e.preventDefault();
setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1));
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) {
e.preventDefault();
selectCommand(filteredCommands[pickerIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowCommandPicker(false);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
e.target.value = '';
for (const file of files) {
if (file.size > 50 * 1024 * 1024) {
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB' }]);
continue;
}
setPendingFiles((prev) => [...prev, { file, progress: 0 }]);
try {
const result = await uploadFile(file, sessionId, (pct) => {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, progress: pct } : item)));
});
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
} catch (err: any) {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item)));
}
}
}, [sessionId]);
const handleNewSession = () => {
const id = `web:${Date.now()}`;
setSessionId(id);
clearMessages();
resetProcessState();
};
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteSession(key);
if (key === sessionId) {
setSessionId('web:default');
clearMessages();
resetProcessState();
}
loadSessions();
} catch {
// ignore transient errors
}
};
const handleSelectSession = (key: string) => {
setSessionId(key);
};
const handleCancelRun = async (runId: string) => {
try {
await cancelDelegation(runId);
} catch (err: any) {
addMessage({
role: 'assistant',
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`,
timestamp: new Date().toISOString(),
});
}
};
const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
}, []);
const formatSessionName = (key: string) => {
if (key.startsWith('web:')) {
const id = key.slice(4);
if (id === 'default') return '默认';
const numeric = Number(id);
if (!Number.isNaN(numeric)) {
return new Date(numeric).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return id;
}
return key;
};
return (
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
<div className="w-64 border-r border-border flex flex-col bg-card">
<div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" />
</Button>
</div>
<Separator />
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{sessions.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-4 text-center"></p>
)}
{sessions.map((session) => (
<div
key={session.key}
onClick={() => handleSelectSession(session.key)}
className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${
session.key === sessionId
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50'
}`}
>
<div className="flex items-center gap-2 truncate">
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{formatSessionName(session.key)}</span>
</div>
<button
onClick={(event) => handleDeleteSession(session.key, event)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
messagesEndRef={messagesEndRef}
messageViewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId}
onCancelRun={handleCancelRun}
/>
</div>
<div className="border-t border-border p-4 bg-background/95 backdrop-blur">
<div className="max-w-5xl mx-auto">
{pendingFiles.length > 0 && (
<div className="mb-2 space-y-1">
{pendingFiles.map((item, index) => (
<div key={`${item.file.name}:${index}`} className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm">
<span className="truncate flex-1">
{item.file.name}{' '}
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
</span>
{item.error ? (
<span className="text-destructive text-xs">{item.error}</span>
) : item.progress < 100 ? (
<div className="w-20 h-1.5 bg-muted-foreground/20 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
) : (
<span className="text-green-500 text-xs"></span>
)}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<div className="relative flex gap-2">
{showCommandPicker && filteredCommands.length > 0 && (
<div
ref={pickerRef}
className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50"
>
{filteredCommands.map((command, index) => (
<button
key={command.name}
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${
index === pickerIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 text-foreground'
}`}
onMouseDown={(event) => {
event.preventDefault();
selectCommand(command);
}}
onMouseEnter={() => setPickerIndex(index)}
>
<span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span>
{command.argument_hint && (
<span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span>
)}
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
{command.plugin_name !== 'builtin' && (
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
{command.plugin_name === 'skill' ? '技能' : command.plugin_name}
</span>
)}
</button>
))}
</div>
)}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<Button
onClick={() => fileInputRef.current?.click()}
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
title="添加附件"
>
<Paperclip className="w-4 h-4" />
</Button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息或 / 呼出命令…回车发送Shift+回车换行)"
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '200px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
}}
/>
<Button
onClick={handleSend}
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
size="icon"
className="h-10 w-10 flex-shrink-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,289 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Blocks,
RefreshCw,
Loader2,
AlertCircle,
Bot,
Terminal,
Wrench,
ChevronDown,
ChevronRight,
Globe,
FolderOpen,
} from 'lucide-react';
import { listPlugins } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { PluginInfo } from '@/types';
export default function PluginsPage() {
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const data = await listPlugins();
setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载插件失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Blocks className="w-6 h-6" />
</h1>
<p className="text-sm text-muted-foreground mt-1">
{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">~/.nanobot/plugins/</code>
{' '}{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">&lt;workspace&gt;/plugins/</code>
</p>
</div>
<Button onClick={load} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Error */}
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Empty state */}
{!error && plugins.length === 0 && (
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
<Blocks className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-2 max-w-sm mx-auto">
<code className="text-xs bg-muted px-1 py-0.5 rounded">~/.nanobot/plugins/</code>
Boardware Genius
</p>
</CardContent>
</Card>
)}
{/* Plugin cards */}
<div className="space-y-4">
{plugins.map((plugin) => (
<PluginCard key={plugin.name} plugin={plugin} />
))}
</div>
</div>
);
}
function PluginCard({ plugin }: { plugin: PluginInfo }) {
const [agentsOpen, setAgentsOpen] = useState(true);
const [commandsOpen, setCommandsOpen] = useState(true);
const [skillsOpen, setSkillsOpen] = useState(false);
const totalItems = plugin.agents.length + plugin.commands.length + plugin.skills.length;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base font-semibold">{plugin.name}</CardTitle>
<SourceBadge source={plugin.source} />
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{plugin.description}
</p>
)}
</div>
{/* Summary chips */}
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
{plugin.agents.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Bot className="w-3 h-3" />
{plugin.agents.length}
</span>
)}
{plugin.commands.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Terminal className="w-3 h-3" />
{plugin.commands.length}
</span>
)}
{plugin.skills.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Wrench className="w-3 h-3" />
{plugin.skills.length}
</span>
)}
</div>
</div>
</CardHeader>
{totalItems > 0 && (
<CardContent className="pt-0 space-y-3">
{/* Agents */}
{plugin.agents.length > 0 && (
<Section
icon={<Bot className="w-3.5 h-3.5" />}
label="智能体"
count={plugin.agents.length}
open={agentsOpen}
onToggle={() => setAgentsOpen((v) => !v)}
>
<div className="divide-y divide-border rounded-md border">
{plugin.agents.map((agent) => (
<div key={agent.name} className="px-3 py-2 flex items-start gap-3">
<code className="text-xs font-mono text-primary shrink-0 mt-0.5">
{agent.name}
</code>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{agent.description || '—'}</p>
</div>
{agent.model && (
<Badge variant="outline" className="text-xs shrink-0">
{agent.model}
</Badge>
)}
</div>
))}
</div>
</Section>
)}
{/* Commands */}
{plugin.commands.length > 0 && (
<Section
icon={<Terminal className="w-3.5 h-3.5" />}
label="命令"
count={plugin.commands.length}
open={commandsOpen}
onToggle={() => setCommandsOpen((v) => !v)}
>
<div className="divide-y divide-border rounded-md border">
{plugin.commands.map((cmd) => (
<div key={cmd.name} className="px-3 py-2 flex items-start gap-3">
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
<code className="text-xs font-mono text-primary">/{cmd.name}</code>
{cmd.argument_hint && (
<span className="text-xs text-muted-foreground">{cmd.argument_hint}</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{cmd.description || '—'}
</p>
</div>
))}
</div>
</Section>
)}
{/* Skills */}
{plugin.skills.length > 0 && (
<Section
icon={<Wrench className="w-3.5 h-3.5" />}
label="技能"
count={plugin.skills.length}
open={skillsOpen}
onToggle={() => setSkillsOpen((v) => !v)}
>
<div className="flex flex-wrap gap-1.5">
{plugin.skills.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs font-mono">
{skill}
</Badge>
))}
</div>
</Section>
)}
</CardContent>
)}
</Card>
);
}
function SourceBadge({ source }: { source: 'global' | 'workspace' }) {
if (source === 'workspace') {
return (
<Badge variant="default" className="text-xs gap-1">
<FolderOpen className="w-3 h-3" />
</Badge>
);
}
return (
<Badge variant="secondary" className="text-xs gap-1">
<Globe className="w-3 h-3" />
</Badge>
);
}
function Section({
icon,
label,
count,
open,
onToggle,
children,
}: {
icon: React.ReactNode;
label: string;
count: number;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div>
<button
onClick={onToggle}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors mb-2 w-full text-left"
>
{open ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
{icon}
{label}
<span className="ml-1 text-muted-foreground/60">({count})</span>
</button>
{open && children}
</div>
);
}

View File

@ -0,0 +1,311 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import {
Puzzle,
Upload,
Download,
Trash2,
RefreshCw,
Loader2,
AlertCircle,
X,
} from 'lucide-react';
import { listSkills, deleteSkill, uploadSkill, downloadSkill } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { Skill } from '@/types';
export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const loadSkills = async () => {
setLoading(true);
setError(null);
try {
const data = await listSkills();
setSkills(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载技能失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSkills();
}, []);
const handleDelete = async (name: string) => {
setDeleting(name);
};
const confirmDelete = async (name: string) => {
try {
await deleteSkill(name);
setDeleting(null);
loadSkills();
} catch (err: any) {
setError(err.message || '删除技能失败');
setDeleting(null);
}
};
const handleUploadDone = () => {
setShowUpload(false);
loadSkills();
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Puzzle className="w-6 h-6" />
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadSkills} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => setShowUpload(true)} size="sm">
<Upload className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Upload Dialog */}
{showUpload && (
<UploadSkillForm
onDone={handleUploadDone}
onCancel={() => setShowUpload(false)}
onError={(msg) => setError(msg)}
/>
)}
{/* Delete Confirmation */}
{deleting && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<p className="text-sm">
<strong>{deleting}</strong>
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleting(null)}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(deleting)}
>
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Skills Table */}
<Card>
<CardContent className="p-0">
{skills.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"> zip 使</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{skills.map((skill) => (
<TableRow key={`${skill.source}:${skill.name}`}>
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
{skill.description}
</span>
</TableCell>
<TableCell>
{skill.source === 'builtin' ? (
<Badge variant="secondary" className="text-xs">
</Badge>
) : (
<Badge variant="default" className="text-xs">
</Badge>
)}
</TableCell>
<TableCell>
{skill.available ? (
<Badge variant="default" className="text-xs bg-green-600">
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="下载"
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
>
<Download className="w-3.5 h-3.5" />
</Button>
{skill.source === 'workspace' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(skill.name)}
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function UploadSkillForm({
onDone,
onCancel,
onError,
}: {
onDone: () => void;
onCancel: () => void;
onError: (msg: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setUploading(true);
try {
await uploadSkill(file);
onDone();
} catch (err: any) {
onError(err.message || '上传失败');
} finally {
setUploading(false);
}
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="skill-zip">
</label>
<input
id="skill-zip"
ref={fileRef}
type="file"
accept=".zip"
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
/>
<p className="text-xs text-muted-foreground">
`SKILL.md`
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit" disabled={uploading}>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,235 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
CheckCircle2,
XCircle,
AlertCircle,
RefreshCw,
Server,
Cpu,
Radio,
Key,
Loader2,
} from 'lucide-react';
import { getStatus } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import type { SystemStatus } from '@/types';
export default function StatusPage() {
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const loadStatus = async () => {
setLoading(true);
setError(null);
try {
const data = await getStatus();
setStatus(data);
} catch (err: any) {
setError(err.message || '连接后端失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStatus();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="max-w-4xl mx-auto p-6">
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-medium"> Boardware Genius </p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="text-sm text-muted-foreground mt-1">
<code className="bg-muted px-1 rounded">nanobot web</code>
</p>
</div>
</div>
<Button onClick={loadStatus} variant="outline" size="sm" className="mt-4">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
</div>
);
}
if (!status) return null;
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<Button onClick={loadStatus} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
{/* System Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow
label="配置"
value={status.config_path}
ok={status.config_exists}
/>
<InfoRow
label="工作区"
value={status.workspace}
ok={status.workspace_exists}
/>
</CardContent>
</Card>
{/* Model Config */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cpu className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label="模型" value={status.model} />
<InfoRow label="最大令牌数" value={String(status.max_tokens)} />
<InfoRow label="温度" value={String(status.temperature)} />
<InfoRow
label="最大工具迭代次数"
value={String(status.max_tool_iterations)}
/>
</CardContent>
</Card>
{/* Providers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Key className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{status.providers.map((p) => (
<div
key={p.name}
className="flex items-center gap-2 text-sm"
>
{p.has_key ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-muted-foreground/40" />
)}
<span className={p.has_key ? '' : 'text-muted-foreground'}>
{p.name}
</span>
{p.detail && (
<span className="text-xs text-muted-foreground truncate">
{p.detail}
</span>
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Channels */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Radio className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{status.channels.map((ch) => (
<div key={ch.name} className="flex items-center gap-2 text-sm">
<Badge
variant={ch.enabled ? 'default' : 'secondary'}
className="text-xs"
>
{ch.enabled ? '开启' : '关闭'}
</Badge>
<span className="capitalize">{ch.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Cron Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow
label="状态"
value={status.cron.enabled ? '运行中' : '已停止'}
ok={status.cron.enabled}
/>
<InfoRow label="任务数" value={String(status.cron.jobs)} />
</CardContent>
</Card>
</div>
);
}
function InfoRow({
label,
value,
ok,
}: {
label: string;
value: string;
ok?: boolean;
}) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<code className="bg-muted px-2 py-0.5 rounded text-xs max-w-[400px] truncate">
{value}
</code>
{ok !== undefined &&
(ok ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-destructive" />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import AuthGuard from '@/components/AuthGuard';
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthGuard minHeightClassName="min-h-screen">
<main className="min-h-screen bg-background text-foreground">{children}</main>
</AuthGuard>
);
}

View File

@ -0,0 +1,21 @@
'use client';
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal';
export default function LoginRedirectPage() {
const searchParams = useSearchParams();
useEffect(() => {
const nextPath = searchParams?.get('next') || '/';
window.location.replace(buildAuthPortalUrl('/login', nextPath));
}, [searchParams]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">...</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
'use client';
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal';
export default function RegisterRedirectPage() {
const searchParams = useSearchParams();
useEffect(() => {
const nextPath = searchParams?.get('next') || '/mcp';
window.location.replace(buildAuthPortalUrl('/register', nextPath));
}, [searchParams]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">...</div>
</div>
);
}

View File

@ -0,0 +1,124 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC",
"Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
}
/* Override Tailwind Typography table defaults for markdown rendering */
.prose table {
margin-top: 0;
margin-bottom: 0;
}
.prose thead th {
padding: 0;
vertical-align: middle;
}
.prose tbody td {
padding: 0;
vertical-align: middle;
}
.prose thead {
border-bottom: none;
}
.prose tbody tr {
border-bottom: none;
}
.prose :where(thead th:first-child) {
padding-inline-start: 0;
}
.prose :where(thead th:last-child) {
padding-inline-end: 0;
}
.prose :where(tbody td:first-child, tfoot td:first-child) {
padding-inline-start: 0;
}
.prose :where(tbody td:last-child, tfoot td:last-child) {
padding-inline-end: 0;
}

View File

@ -0,0 +1,137 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { clearTokens, consumeHandoffCode, getMe, setTokens } from '@/lib/api';
import { useChatStore } from '@/lib/store';
const HANDOFF_STATE_KEY = 'nanobot_handoff_state';
type HandoffState = {
code?: string;
accessToken?: string;
refreshToken?: string;
nextPath?: string;
};
function parseHandoffStateFromLocation(): HandoffState {
if (typeof window === 'undefined') {
return {};
}
const query = new URLSearchParams(window.location.search);
const code = query.get('code') || '';
const nextFromQuery = query.get('next') || '';
if (code) {
return {
code,
nextPath: nextFromQuery || '/',
};
}
const rawHash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
const hash = new URLSearchParams(rawHash);
const accessToken = hash.get('access_token') || '';
if (accessToken) {
return {
accessToken,
refreshToken: hash.get('refresh_token') || '',
nextPath: hash.get('next') || '/',
};
}
return {};
}
function loadHandoffState(): HandoffState {
if (typeof window === 'undefined') {
return {};
}
const fromLocation = parseHandoffStateFromLocation();
if (fromLocation.code || fromLocation.accessToken) {
sessionStorage.setItem(HANDOFF_STATE_KEY, JSON.stringify(fromLocation));
return fromLocation;
}
const cached = sessionStorage.getItem(HANDOFF_STATE_KEY) || '';
if (!cached) {
return {};
}
try {
const parsed = JSON.parse(cached) as HandoffState;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function clearHandoffState(): void {
if (typeof window === 'undefined') {
return;
}
sessionStorage.removeItem(HANDOFF_STATE_KEY);
}
export default function HandoffPage() {
const router = useRouter();
const setUser = useChatStore((s) => s.setUser);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
const run = async () => {
const handoff = loadHandoffState();
const nextPath = handoff.nextPath || '/';
if (!handoff.code && !handoff.accessToken) {
clearHandoffState();
setError('缺少登录凭证,无法进入目标前端。');
return;
}
window.history.replaceState(null, '', '/handoff');
try {
const tokenPayload = handoff.accessToken
? {
access_token: handoff.accessToken,
refresh_token: handoff.refreshToken || '',
}
: await consumeHandoffCode(handoff.code || '');
setTokens(tokenPayload.access_token, tokenPayload.refresh_token || '');
const me = await getMe();
if (cancelled) return;
clearHandoffState();
setUser(me);
router.replace(nextPath.startsWith('/') ? nextPath : '/');
} catch (err) {
clearHandoffState();
clearTokens();
if (cancelled) return;
setError(err instanceof Error ? err.message : '目标前端登录失败');
}
};
void run();
return () => {
cancelled = true;
};
}, [router, setUser]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-center">
<h1 className="text-xl font-semibold">...</h1>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : <p className="mt-3 text-sm text-muted-foreground"></p>}
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Boardware Genius',
description: '个人 AI 助手',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className="dark">
<body className="bg-background text-foreground">{children}</body>
</html>
);
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@ -0,0 +1,100 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { clearTokens, getMe, isLoggedIn } from '@/lib/api';
import { useChatStore } from '@/lib/store';
export default function AuthGuard({
children,
minHeightClassName = 'min-h-[calc(100vh-3.5rem)]',
}: {
children: React.ReactNode;
minHeightClassName?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const user = useChatStore((s) => s.user);
const setUser = useChatStore((s) => s.setUser);
const setIsAuthLoading = useChatStore((s) => s.setIsAuthLoading);
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
useEffect(() => {
let cancelled = false;
const init = async () => {
if (!isLoggedIn()) {
setUser(null);
if (!cancelled) {
setIsAuthLoading(false);
}
return;
}
if (useChatStore.getState().user) {
if (!cancelled) {
setIsAuthLoading(false);
}
return;
}
setIsAuthLoading(true);
try {
const me = await getMe();
if (cancelled) return;
setUser(me);
} catch {
clearTokens();
if (cancelled) return;
setUser(null);
} finally {
if (!cancelled) {
setIsAuthLoading(false);
}
}
};
init();
return () => {
cancelled = true;
};
}, [setIsAuthLoading, setUser]);
useEffect(() => {
if (isAuthLoading) {
return;
}
const isPublicRoute = pathname === '/login' || pathname === '/register';
const loggedIn = isLoggedIn();
if (!loggedIn && !isPublicRoute) {
const search = searchParams?.toString();
const nextPath = search ? `${pathname}?${search}` : pathname;
window.location.replace(buildAuthPortalUrl('/login', nextPath));
return;
}
if (loggedIn && user && isPublicRoute) {
router.replace('/');
}
}, [isAuthLoading, pathname, router, searchParams, user]);
if (isAuthLoading) {
return (
<div className={`flex ${minHeightClassName} items-center justify-center`}>
<div className="text-muted-foreground">...</div>
</div>
);
}
const isPublicRoute = pathname === '/login' || pathname === '/register';
if (!isPublicRoute && (!isLoggedIn() || !user)) {
return null;
}
return <>{children}</>;
}

View File

@ -0,0 +1,154 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, UserCircle2 } from 'lucide-react';
import { logout } from '@/lib/api';
import { useChatStore } from '@/lib/store';
const NAV_ITEMS = [
{ name: '对话', href: '/', icon: MessageSquare },
{ name: '状态', href: '/status', icon: Activity },
{ name: '定时任务', href: '/cron', icon: Clock },
{ name: '技能', href: '/skills', icon: Puzzle },
{ name: '插件', href: '/plugins', icon: Blocks },
{ name: '智能体', href: '/agents', icon: Bot },
{ name: 'MCP', href: '/mcp', icon: ServerCog },
{ name: 'Outlook', href: '/outlook', icon: Mail },
{ name: '市场', href: '/marketplace', icon: Store },
{ name: '文件', href: '/files', icon: FolderOpen },
{ name: '帮助', href: '/help', icon: HelpCircle },
];
const AUTH_ITEMS = [
{ name: '登录', href: '/login', icon: LogIn },
{ name: '注册', href: '/register', icon: UserPlus },
];
function ConnectionDot() {
const wsStatus = useChatStore((s) => s.wsStatus);
const nanobotReady = useChatStore((s) => s.nanobotReady);
const isOnline = wsStatus === 'connected' && nanobotReady === true;
const isChecking = wsStatus === 'connected' && nanobotReady === null;
const isConnecting = wsStatus === 'connecting' || isChecking;
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
const color = isOnline
? 'bg-green-500'
: isConnecting
? 'bg-yellow-500'
: 'bg-red-500';
const label = isOnline
? '已连接'
: isChecking
? '检查中'
: wsStatus === 'connecting'
? '连接中'
: isOffline && wsStatus === 'connected'
? '服务离线'
: '未连接';
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span>{label}</span>
</div>
);
}
const Header = () => {
const pathname = usePathname();
const router = useRouter();
const user = useChatStore((s) => s.user);
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
const setUser = useChatStore((s) => s.setUser);
const handleLogout = async () => {
await logout();
setUser(null);
router.replace('/login');
router.refresh();
};
return (
<header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50">
<div className="max-w-[1560px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16 gap-4">
<Link href="/" className="flex items-center space-x-2">
<span className="text-xl">🐈</span>
<span className="text-base font-bold sm:text-lg">Boardware Genius</span>
</Link>
<nav className="flex items-center gap-1.5 whitespace-nowrap">
{NAV_ITEMS.map((item) => {
const isActive =
item.href === '/'
? pathname === '/'
: pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Icon className="w-4 h-4" />
{item.name}
</Link>
);
})}
<div className="ml-2 pl-4 border-l border-border flex items-center gap-1.5">
{user ? (
<>
<div className="flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium text-foreground">
<UserCircle2 className="w-4 h-4" />
<span className="max-w-32 truncate">{user.username}</span>
</div>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LogOut className="w-4 h-4" />
退
</button>
</>
) : !isAuthLoading ? (
AUTH_ITEMS.map((item) => {
const isActive = pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Icon className="w-4 h-4" />
{item.name}
</Link>
);
})
) : null}
</div>
<div className="ml-4 pl-4 border-l border-border">
<ConnectionDot />
</div>
</nav>
</div>
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,187 @@
'use client';
import { FileJson, FileOutput, FolderSearch, Image as ImageIcon, Link2, MessagesSquare } from 'lucide-react';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
function statusLabel(status: string) {
if (status === 'done') return '已完成';
if (status === 'error') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'waiting') return '等待中';
if (status === 'running') return '运行中';
if (status === 'queued') return '排队中';
return status;
}
function actorTypeLabel(actorType: string) {
if (actorType === 'mcp') return 'MCP';
if (actorType === 'system') return '系统';
if (actorType === 'agent') return '智能体';
return actorType;
}
function eventKindLabel(kind: string) {
if (kind === 'run_started') return '已启动';
if (kind === 'run_progress') return '进行中';
if (kind === 'run_status') return '状态更新';
if (kind === 'run_artifact') return '产物';
if (kind === 'run_finished') return '已结束';
if (kind === 'run_cancelled') return '已取消';
return kind;
}
function artifactIcon(type: ProcessArtifact['artifact_type']) {
if (type === 'json') return <FileJson className="w-4 h-4" />;
if (type === 'image') return <ImageIcon className="w-4 h-4" />;
if (type === 'link') return <Link2 className="w-4 h-4" />;
return <FileOutput className="w-4 h-4" />;
}
function renderArtifactBody(artifact: ProcessArtifact) {
if (artifact.artifact_type === 'json' && artifact.data !== undefined) {
return (
<pre className="text-[11px] leading-5 whitespace-pre-wrap break-words rounded-md bg-background/70 p-3 overflow-x-auto">
{JSON.stringify(artifact.data, null, 2)}
</pre>
);
}
if (artifact.artifact_type === 'link' && artifact.url) {
return (
<a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-sky-300 underline break-all">
{artifact.url}
</a>
);
}
return (
<div className="text-xs text-foreground/90 whitespace-pre-wrap break-words">
{artifact.content || '(空产物)'}
</div>
);
}
export function ArtifactSidebar({
selectedRun,
events,
artifacts,
}: {
selectedRun: ProcessRun | null;
events: ProcessEvent[];
artifacts: ProcessArtifact[];
}) {
const runArtifacts = selectedRun
? artifacts.filter((item) => item.run_id === selectedRun.run_id)
: artifacts;
const runEvents = selectedRun
? events.filter((item) => item.run_id === selectedRun.run_id)
: events.slice(-12);
const hasContent = Boolean(
selectedRun || runArtifacts.length > 0 || runEvents.length > 0
);
if (!hasContent) {
return null;
}
return (
<div className="h-full bg-card/60 flex flex-col border-l border-border">
<div className="px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground"></h2>
<p className="text-xs text-muted-foreground mt-1">
{selectedRun ? `当前选中: ${selectedRun.actor_name}` : '选择一个任务查看详细过程与产物'}
</p>
</div>
<ScrollArea className="flex-1 px-4 py-4">
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<FolderSearch className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2 text-sm">
{selectedRun ? (
<>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">{actorTypeLabel(selectedRun.actor_type)}</Badge>
<Badge variant="outline">{statusLabel(selectedRun.status)}</Badge>
{selectedRun.source && <Badge variant="secondary">{selectedRun.source}</Badge>}
</div>
<div className="font-medium">{selectedRun.title}</div>
<div className="text-muted-foreground whitespace-pre-wrap break-words">
{selectedRun.summary || '暂时还没有最终摘要。'}
</div>
</>
) : (
<div className="text-muted-foreground text-sm"></div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<MessagesSquare className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{runEvents.length === 0 && (
<div className="text-xs text-muted-foreground"></div>
)}
{runEvents.map((event, index) => (
<div key={event.event_id}>
<div className="rounded-md border border-border/60 px-3 py-2 bg-background/60">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
<span>{eventKindLabel(event.kind)}</span>
{event.status && <span>{statusLabel(event.status)}</span>}
</div>
<div className="text-xs whitespace-pre-wrap break-words">
{event.text || '结构化更新'}
</div>
</div>
{index < runEvents.length - 1 && <Separator className="my-2" />}
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<FileOutput className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{runArtifacts.length === 0 && (
<div className="text-xs text-muted-foreground"></div>
)}
{runArtifacts.map((artifact) => (
<div key={artifact.artifact_id} className="rounded-lg border border-border/70 bg-background/70 p-3 space-y-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
{artifactIcon(artifact.artifact_type)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{artifact.title}</div>
<div className="text-[11px] text-muted-foreground">
{artifact.actor_id} · {artifact.artifact_type}
</div>
</div>
</div>
{renderArtifactBody(artifact)}
</div>
))}
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,147 @@
'use client';
import React from 'react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MessageList } from '@/components/chat-workbench/MessageList';
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
export function ChatWorkbench({
messages,
isThinking,
messagesEndRef,
messageViewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onCancelRun,
}: {
messages: ChatMessage[];
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
messageViewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const selectedRun = processRuns.find((item) => item.run_id === selectedRunId) || processRuns[0] || null;
const selectedRunEvents = selectedRun
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
: [];
const selectedRunArtifacts = selectedRun
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
: [];
const hasProcessLane = processRuns.length > 0;
const hasResultsPanel = Boolean(
selectedRun &&
(
selectedRun.summary ||
selectedRunEvents.length > 0 ||
selectedRunArtifacts.length > 0
)
);
const desktopColumns = hasProcessLane && hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px_360px]'
: hasProcessLane
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: 'lg:grid-cols-[minmax(0,1fr)]';
return (
<>
<div className={`hidden lg:grid h-full ${desktopColumns}`}>
<div className="min-h-0">
<MessageList
messages={messages}
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
/>
</div>
{hasProcessLane && (
<div className="min-h-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</div>
)}
{hasResultsPanel && (
<div className="min-h-0">
<ArtifactSidebar
selectedRun={selectedRun}
events={processEvents}
artifacts={processArtifacts}
/>
</div>
)}
</div>
<div className="lg:hidden h-full">
{!hasProcessLane && !hasResultsPanel ? (
<MessageList
messages={messages}
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
/>
) : (
<Tabs defaultValue="chat" className="h-full flex flex-col">
<div className="px-4 pt-3 border-b border-border">
<TabsList
className={`grid w-full ${
hasProcessLane && hasResultsPanel
? 'grid-cols-3'
: 'grid-cols-2'
}`}
>
<TabsTrigger value="chat"></TabsTrigger>
{hasProcessLane && <TabsTrigger value="process"></TabsTrigger>}
{hasResultsPanel && <TabsTrigger value="results"></TabsTrigger>}
</TabsList>
</div>
<TabsContent value="chat" className="flex-1 min-h-0 mt-0">
<MessageList
messages={messages}
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
/>
</TabsContent>
{hasProcessLane && (
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</TabsContent>
)}
{hasResultsPanel && (
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
<ArtifactSidebar
selectedRun={selectedRun}
events={processEvents}
artifacts={processArtifacts}
/>
</TabsContent>
)}
</Tabs>
)}
</div>
</>
);
}

View File

@ -0,0 +1,45 @@
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children, ...props }) => (
<div className="my-3 overflow-x-auto rounded-lg border border-border">
<table className="w-full border-collapse text-sm" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-muted/60" {...props}>
{children}
</thead>
),
th: ({ children, ...props }) => (
<th className="px-3 py-2 text-left font-semibold text-foreground border-b border-border" {...props}>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="px-3 py-2 border-b border-border/50" {...props}>
{children}
</td>
),
tr: ({ children, ...props }) => (
<tr className="hover:bg-muted/30 transition-colors" {...props}>
{children}
</tr>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@ -0,0 +1,149 @@
'use client';
import React from 'react';
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
import type { ChatMessage } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
React.useEffect(() => {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
let revoke: string | null = null;
fetch(src, { headers })
.then((res) => res.blob())
.then((blob) => {
revoke = URL.createObjectURL(blob);
setBlobUrl(revoke);
})
.catch(() => {});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [src]);
if (!blobUrl) return <div className="w-32 h-32 bg-muted animate-pulse rounded" />;
return <img src={blobUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
}
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
return (
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && (
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bot className="w-4 h-4 text-primary" />
</div>
)}
<div
className={`rounded-xl px-4 py-3 max-w-[88%] shadow-sm ${
isUser
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border/80'
}`}
>
{message.attachments && message.attachments.length > 0 && (
<div className="mb-2 space-y-2">
{message.attachments.map((att) => {
const fileUrl = getFileUrl(att.file_id);
if (att.content_type.startsWith('image/')) {
return (
<a key={att.file_id} href={fileUrl} target="_blank" rel="noopener noreferrer">
<AuthImage
src={fileUrl}
alt={att.name}
className="max-w-xs max-h-60 rounded border border-border/50 cursor-pointer hover:opacity-90"
/>
</a>
);
}
return (
<a
key={att.file_id}
href={fileUrl}
download={att.name}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
isUser
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
: 'bg-muted hover:bg-muted/80'
}`}
>
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-70 flex-shrink-0">
{att.size > 1024 * 1024
? `${(att.size / 1024 / 1024).toFixed(1)}MB`
: `${(att.size / 1024).toFixed(0)}KB`}
</span>
)}
</a>
);
})}
</div>
)}
{isUser ? (
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
) : (
<MarkdownContent content={textContent} />
)}
</div>
{isUser && (
<div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5">
<User className="w-4 h-4" />
</div>
)}
</div>
);
}
export function MessageList({
messages,
isThinking,
messagesEndRef,
viewportRef,
}: {
messages: ChatMessage[];
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: React.RefObject<HTMLDivElement>;
}) {
return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
<div className="max-w-4xl mx-auto py-4 space-y-4">
{messages.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">Boardware Genius</p>
<p className="text-sm"></p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
))}
{isThinking && (
<div className="flex items-center gap-2 text-muted-foreground px-1">
<Bot className="w-5 h-5" />
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
);
}

View File

@ -0,0 +1,186 @@
'use client';
import { AlertCircle, Bot, BrainCircuit, Loader2, ServerCog, Square } from 'lucide-react';
import type { ProcessEvent, ProcessRun } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
function statusLabel(status: string) {
if (status === 'done') return '已完成';
if (status === 'error') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'waiting') return '等待中';
if (status === 'running') return '运行中';
if (status === 'queued') return '排队中';
return status;
}
function actorTypeLabel(actorType: string) {
if (actorType === 'mcp') return 'MCP';
if (actorType === 'system') return '系统';
if (actorType === 'agent') return '智能体';
return actorType;
}
function eventKindLabel(kind: string) {
if (kind === 'run_started') return '已启动';
if (kind === 'run_progress') return '进行中';
if (kind === 'run_status') return '状态更新';
if (kind === 'run_artifact') return '产物';
if (kind === 'run_finished') return '已结束';
if (kind === 'run_cancelled') return '已取消';
return kind;
}
function statusTone(status: string) {
if (status === 'done') return 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20';
if (status === 'error') return 'bg-rose-500/10 text-rose-300 border-rose-500/20';
if (status === 'cancelled') return 'bg-zinc-500/10 text-zinc-300 border-zinc-500/20';
if (status === 'waiting') return 'bg-amber-500/10 text-amber-300 border-amber-500/20';
return 'bg-sky-500/10 text-sky-300 border-sky-500/20';
}
function actorIcon(run: ProcessRun) {
if (run.actor_type === 'mcp') return <ServerCog className="w-4 h-4" />;
if (run.actor_type === 'system') return <BrainCircuit className="w-4 h-4" />;
return <Bot className="w-4 h-4" />;
}
export function ProcessLane({
runs,
events,
selectedRunId,
onSelectRun,
onCancelRun,
}: {
runs: ProcessRun[];
events: ProcessEvent[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const sortedRuns = [...runs].sort((a, b) => {
const at = new Date(a.started_at).getTime();
const bt = new Date(b.started_at).getTime();
return bt - at;
});
if (sortedRuns.length === 0) {
return null;
}
return (
<div className="h-full flex flex-col bg-card/60 border-l border-border">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground"></h2>
<p className="text-xs text-muted-foreground mt-1">A2AMCP </p>
</div>
<Badge variant="outline" className="text-xs">
{sortedRuns.length}
</Badge>
</div>
<ScrollArea className="flex-1 px-4 py-4">
<div className="space-y-3">
{sortedRuns.map((run) => {
const runEvents = events
.filter((event) => event.run_id === run.run_id)
.slice(-5)
.reverse();
const isSelected = run.run_id === selectedRunId;
const canCancel =
!run.parent_run_id &&
run.actor_type !== 'mcp' &&
(run.status === 'running' || run.status === 'waiting');
return (
<Card
key={run.run_id}
className={cn(
'cursor-pointer transition-colors border-border/80 hover:border-primary/40',
isSelected && 'border-primary bg-primary/5'
)}
onClick={() => onSelectRun(run.run_id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
{actorIcon(run)}
</div>
<div className="min-w-0">
<CardTitle className="text-sm leading-none truncate">{run.actor_name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 truncate">{run.title}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className={cn('text-[10px] border', statusTone(run.status))}>
{statusLabel(run.status)}
</Badge>
{canCancel && (
<Button
variant="outline"
size="sm"
className="h-7 px-2"
onClick={(event) => {
event.stopPropagation();
onCancelRun(run.run_id);
}}
>
<Square className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>{actorTypeLabel(run.actor_type)}</span>
{run.source && <span>{run.source}</span>}
{run.parent_run_id && <span></span>}
</div>
{run.summary && (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
{run.summary}
</div>
)}
<div className="space-y-1.5">
{runEvents.length === 0 && run.status === 'running' && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</div>
)}
{runEvents.map((event) => (
<div key={event.event_id} className="text-xs rounded-md border border-border/50 bg-background/60 px-3 py-2">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
<span>{eventKindLabel(event.kind)}</span>
{event.status && <span>{statusLabel(event.status)}</span>}
</div>
<div className="text-foreground/90 whitespace-pre-wrap break-words">
{event.text || '结构化更新'}
</div>
</div>
))}
{run.status === 'error' && (
<div className="flex items-center gap-2 text-xs text-rose-300">
<AlertCircle className="w-3.5 h-3.5" />
</div>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,7 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,36 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,115 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -0,0 +1,56 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@ -0,0 +1,86 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,262 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@ -0,0 +1,365 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center'
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center'
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
);
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,179 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -0,0 +1,71 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import { cn } from '@/lib/utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,26 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,236 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = 'MenubarShortcut';
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@ -0,0 +1,128 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
'relative z-10 flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
'group flex flex-1 list-none items-center justify-center space-x-1',
className
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
className
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn('absolute left-0 top-full flex justify-center')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@ -0,0 +1,117 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ButtonProps, buttonVariants } from '@/components/ui/button';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,45 @@
'use client';
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>;
}
>(({ className, children, viewportRef, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
className="h-full w-full rounded-[inherit]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,160 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
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}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,31 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,117 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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',
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,4 @@
# 单用户后端地址nanobot web
NEXT_PUBLIC_API_URL=http://127.0.0.1:10000
NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000
NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081

View File

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
'use client';
const AUTH_PORTAL_URL = process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim();
const AUTH_PORTAL_PORT = process.env.NEXT_PUBLIC_AUTH_PORTAL_PORT?.trim() || '3081';
function normalizeBaseUrl(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
return trimmed.replace(/\/+$/, '');
}
function getPortalBaseUrl(): string {
const explicit = normalizeBaseUrl(AUTH_PORTAL_URL);
if (explicit) return explicit;
if (typeof window !== 'undefined') {
const url = new URL(window.location.origin);
url.port = AUTH_PORTAL_PORT;
return normalizeBaseUrl(url.toString()) || window.location.origin;
}
return `http://127.0.0.1:${AUTH_PORTAL_PORT}`;
}
export function buildAuthPortalUrl(path: '/login' | '/register', nextPath?: string | null): string {
const url = new URL(path, `${getPortalBaseUrl()}/`);
const nextValue = nextPath?.trim();
if (nextValue) {
url.searchParams.set('next', nextValue);
}
return url.toString();
}

View File

@ -0,0 +1,302 @@
import { create } from 'zustand';
import type {
AuthUser,
ChatMessage,
ProcessArtifact,
ProcessEvent,
ProcessRun,
ProcessWsEvent,
Session,
UiAgentDescriptor,
UiMcpServerDescriptor,
} from '@/types';
import type { WsStatus } from '@/lib/api';
interface ChatStore {
user: AuthUser | null;
isAuthLoading: boolean;
sessionId: string;
messages: ChatMessage[];
isLoading: boolean;
streamingContent: string;
wsStatus: WsStatus;
isThinking: boolean;
nanobotReady: boolean | null;
sessions: Session[];
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
selectedArtifactId: string | null;
agentRegistry: UiAgentDescriptor[];
mcpRegistry: UiMcpServerDescriptor[];
mcpToolRegistry: Array<{ server_id: string; tools: Array<Record<string, unknown>> }>;
lastCancelAck: { runId: string; ok: boolean } | null;
setUser: (user: AuthUser | null) => void;
setIsAuthLoading: (loading: boolean) => void;
setSessionId: (id: string) => void;
setMessages: (msgs: ChatMessage[]) => void;
addMessage: (msg: ChatMessage) => void;
setIsLoading: (loading: boolean) => void;
setStreamingContent: (content: string) => void;
appendStreamingContent: (chunk: string) => void;
setSessions: (sessions: Session[]) => void;
clearMessages: () => void;
setWsStatus: (status: WsStatus) => void;
setIsThinking: (thinking: boolean) => void;
setNanobotReady: (ready: boolean | null) => void;
resetProcessState: () => void;
ingestProcessEvent: (event: ProcessWsEvent) => void;
setSelectedRunId: (runId: string | null) => void;
setSelectedArtifactId: (artifactId: string | null) => void;
setAgentRegistry: (agents: UiAgentDescriptor[]) => void;
setMcpRegistry: (servers: UiMcpServerDescriptor[]) => void;
setMcpToolRegistry: (tools: Array<{ server_id: string; tools: Array<Record<string, unknown>> }>) => void;
}
function upsertRun(collection: ProcessRun[], run: ProcessRun): ProcessRun[] {
const index = collection.findIndex((item) => item.run_id === run.run_id);
if (index === -1) {
return [...collection, run];
}
const next = [...collection];
next[index] = {
...next[index],
...run,
metadata: {
...(next[index].metadata ?? {}),
...(run.metadata ?? {}),
},
};
return next;
}
function upsertArtifact(collection: ProcessArtifact[], artifact: ProcessArtifact): ProcessArtifact[] {
const index = collection.findIndex((item) => item.artifact_id === artifact.artifact_id);
if (index === -1) {
return [...collection, artifact];
}
const next = [...collection];
next[index] = artifact;
return next;
}
function appendEvent(collection: ProcessEvent[], event: ProcessEvent): ProcessEvent[] {
if (collection.some((item) => item.event_id === event.event_id)) {
return collection;
}
return [...collection, event];
}
function createEventId(event: ProcessWsEvent): string {
if (event.type === 'process_cancel_ack') {
return `${event.type}:${event.run_id}`;
}
const suffix =
'text' in event && typeof event.text === 'string'
? event.text
: 'title' in event && typeof event.title === 'string'
? event.title
: event.type;
return `${event.type}:${event.run_id}:${event.created_at}:${suffix}`;
}
export const useChatStore = create<ChatStore>((set) => ({
user: null,
isAuthLoading: true,
sessionId: 'web:default',
messages: [],
isLoading: false,
streamingContent: '',
wsStatus: 'disconnected',
isThinking: false,
nanobotReady: null,
sessions: [],
processRuns: [],
processEvents: [],
processArtifacts: [],
selectedRunId: null,
selectedArtifactId: null,
agentRegistry: [],
mcpRegistry: [],
mcpToolRegistry: [],
lastCancelAck: null,
setUser: (user) => set({ user }),
setIsAuthLoading: (loading) => set({ isAuthLoading: loading }),
setSessionId: (id) => set({ sessionId: id }),
setMessages: (msgs) => set({ messages: msgs }),
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
setIsLoading: (loading) => set({ isLoading: loading }),
setStreamingContent: (content) => set({ streamingContent: content }),
appendStreamingContent: (chunk) =>
set((s) => ({ streamingContent: s.streamingContent + chunk })),
setSessions: (sessions) => set({ sessions }),
clearMessages: () => set({ messages: [], streamingContent: '' }),
setWsStatus: (status) => set({ wsStatus: status }),
setIsThinking: (thinking) => set({ isThinking: thinking }),
setNanobotReady: (ready) => set({ nanobotReady: ready }),
resetProcessState: () =>
set({
processRuns: [],
processEvents: [],
processArtifacts: [],
selectedRunId: null,
selectedArtifactId: null,
lastCancelAck: null,
}),
ingestProcessEvent: (event) =>
set((state) => {
if (event.type === 'process_cancel_ack') {
return {
lastCancelAck: {
runId: event.run_id,
ok: event.ok,
},
};
}
const eventId = createEventId(event);
let nextRuns = state.processRuns;
let nextArtifacts = state.processArtifacts;
let nextSelectedRunId = state.selectedRunId;
const nextEvents = appendEvent(state.processEvents, {
event_id: eventId,
run_id: event.run_id,
parent_run_id: 'parent_run_id' in event ? event.parent_run_id ?? null : null,
kind:
event.type === 'process_run_started'
? 'run_started'
: event.type === 'process_run_progress'
? 'run_progress'
: event.type === 'process_run_status'
? 'run_status'
: event.type === 'process_run_artifact'
? 'run_artifact'
: event.type === 'process_run_finished'
? 'run_finished'
: 'run_cancelled',
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
text:
'text' in event && typeof event.text === 'string'
? event.text
: 'summary' in event && typeof event.summary === 'string'
? event.summary
: undefined,
status: 'status' in event ? event.status : undefined,
metadata: 'metadata' in event ? event.metadata : undefined,
created_at: event.created_at,
});
if (event.type === 'process_run_started') {
nextRuns = upsertRun(nextRuns, {
run_id: event.run_id,
parent_run_id: event.parent_run_id ?? null,
session_id: event.session_id ?? state.sessionId,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title: event.title,
source: event.source ?? null,
status: event.status,
started_at: event.created_at,
metadata: event.metadata,
});
nextSelectedRunId = event.run_id;
}
if (event.type === 'process_run_status') {
nextRuns = upsertRun(nextRuns, {
run_id: event.run_id,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title:
nextRuns.find((item) => item.run_id === event.run_id)?.title ||
event.actor_name,
status: event.status,
started_at:
nextRuns.find((item) => item.run_id === event.run_id)?.started_at ||
event.created_at,
});
}
if (event.type === 'process_run_progress') {
const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, {
run_id: event.run_id,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title: current?.title || event.actor_name,
status: current?.status || 'running',
started_at: current?.started_at || event.created_at,
});
}
if (event.type === 'process_run_artifact') {
nextArtifacts = upsertArtifact(nextArtifacts, {
artifact_id: `${event.run_id}:${event.created_at}:${event.title}`,
run_id: event.run_id,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title: event.title,
artifact_type: event.artifact_type,
content: event.content,
data: event.data,
file_id: event.file_id,
url: event.url,
metadata: event.metadata,
created_at: event.created_at,
});
nextSelectedRunId = event.run_id;
}
if (event.type === 'process_run_finished') {
const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, {
run_id: event.run_id,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title: current?.title || event.actor_name,
status: event.status,
started_at: current?.started_at || event.created_at,
finished_at: event.created_at,
summary: event.summary ?? current?.summary ?? null,
metadata: event.metadata,
});
}
if (event.type === 'process_run_cancelled') {
const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, {
run_id: event.run_id,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title: current?.title || event.actor_name,
status: 'cancelled',
started_at: current?.started_at || event.created_at,
finished_at: event.created_at,
summary: current?.summary ?? '已取消',
});
}
return {
processRuns: nextRuns,
processEvents: nextEvents,
processArtifacts: nextArtifacts,
selectedRunId: nextSelectedRunId,
};
}),
setSelectedRunId: (runId) => set({ selectedRunId: runId }),
setSelectedArtifactId: (artifactId) => set({ selectedArtifactId: artifactId }),
setAgentRegistry: (agents) => set({ agentRegistry: agents }),
setMcpRegistry: (servers) => set({ mcpRegistry: servers }),
setMcpToolRegistry: (tools) => set({ mcpToolRegistry: tools }),
}));

View 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));
}

View File

@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
distDir: process.env.NODE_ENV === 'development' ? '.next-dev' : '.next',
output: 'standalone',
eslint: {
ignoreDuringBuilds: true,
},
images: { unoptimized: true },
};
module.exports = nextConfig;

View File

@ -0,0 +1,19 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
# 对于单页应用 (SPA),所有路由都应指向 index.html
try_files $uri /index.html;
}
# 可以添加一个代理将 API 请求转发到后端以解决CORS问题
# location /api/ {
# proxy_pass http://backend:8000/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# }
}

10170
app-instance/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,93 @@
{
"name": "nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3080 -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@next/swc-wasm-nodejs": "13.5.1",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@supabase/supabase-js": "^2.58.0",
"@tailwindcss/typography": "^0.5.19",
"@types/file-saver": "^2.0.7",
"@types/jspdf": "^1.3.3",
"@types/node": "20.6.2",
"@types/react": "18.2.22",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.15",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"docx": "^9.5.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"embla-carousel-react": "^8.3.0",
"eslint": "8.49.0",
"eslint-config-next": "13.5.1",
"file-saver": "^2.0.5",
"input-otp": "^1.2.4",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.446.0",
"next": "13.5.1",
"next-themes": "^0.3.0",
"pdfmake": "^0.2.20",
"pdfmake-with-chinese-fonts": "^1.0.16",
"postcss": "8.4.30",
"react": "18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-force-graph-2d": "^1.29.0",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-resize-detector": "^12.3.0",
"recharts": "^2.12.7",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss": "3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.2.2",
"vaul": "^0.9.9",
"xlsx": "^0.18.5",
"zod": "^3.23.8",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/pdfmake": "^0.2.12"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1 @@
# Public 目录

View File

@ -0,0 +1,93 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [
require('tailwindcss-animate'),
require('@tailwindcss/typography'),
],
};
export default config;

View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next-dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,605 @@
// Nanobot frontend types
export interface AuthUser {
id: string;
username: string;
email: string;
role: string;
quota_tier: string;
}
export interface BackendConnectionInfo {
backend_id?: string | null;
client_id?: string | null;
name?: string | null;
public_base_url?: string | null;
api_base_url?: string | null;
ws_base_url?: string | null;
frontend_base_url?: string | null;
registered?: boolean;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
user_id: string;
username: string;
role: string;
handoff_code?: string;
handoff_expires_at?: number;
backend_connection?: BackendConnectionInfo | null;
local_backend?: AuthzLocalBackendStatus | null;
}
export interface FileAttachment {
file_id: string;
name: string;
content_type: string;
size?: number;
url?: string;
}
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
timestamp?: string;
attachments?: FileAttachment[];
}
export interface Session {
key: string;
created_at?: string;
updated_at?: string;
path?: string;
}
export interface SessionDetail {
key: string;
messages: ChatMessage[];
created_at: string;
updated_at: string;
}
export interface ProviderStatus {
name: string;
has_key: boolean;
detail?: string;
}
export interface ChannelStatus {
name: string;
enabled: boolean;
}
export interface SystemStatus {
config_path: string;
config_exists: boolean;
workspace: string;
workspace_exists: boolean;
model: string;
max_tokens: number;
temperature: number;
max_tool_iterations: number;
providers: ProviderStatus[];
channels: ChannelStatus[];
cron: {
enabled: boolean;
jobs: number;
next_wake_at_ms: number | null;
};
authz?: {
enabled: boolean;
base_url: string;
outlook_mcp_url?: string;
backend_id?: string;
client_id?: string;
registered: boolean;
};
}
export interface Skill {
name: string;
description: string;
source: 'builtin' | 'workspace';
available: boolean;
path: string;
agent_cards?: Record<string, unknown>[];
}
export interface SlashCommand {
name: string;
description: string;
argument_hint: string | null;
plugin_name: string;
}
export interface PluginAgent {
name: string;
description: string;
model: string | null;
}
export interface PluginCommand {
name: string;
description: string;
argument_hint: string | null;
}
export interface PluginInfo {
name: string;
description: string;
source: 'global' | 'workspace';
agents: PluginAgent[];
commands: PluginCommand[];
skills: string[];
}
export interface CronJob {
id: string;
name: string;
enabled: boolean;
schedule_kind: 'at' | 'every' | 'cron';
schedule_display: string;
schedule_expr: string | null;
schedule_every_ms: number | null;
message: string;
deliver: boolean;
channel: string | null;
to: string | null;
next_run_at_ms: number | null;
last_run_at_ms: number | null;
last_status: string | null;
last_error: string | null;
created_at_ms: number;
}
export interface Marketplace {
name: string;
source: string;
type: 'local' | 'git';
}
export interface MarketplacePlugin {
name: string;
description: string;
marketplace_name: string;
installed: boolean;
}
export type ProcessActorType = 'agent' | 'mcp' | 'system';
export type ProcessRunStatus =
| 'queued'
| 'running'
| 'waiting'
| 'done'
| 'error'
| 'cancelled';
export type ProcessEventKind =
| 'run_started'
| 'run_progress'
| 'run_message'
| 'run_artifact'
| 'run_status'
| 'run_finished'
| 'run_cancelled';
export interface UiAgentDescriptor {
id: string;
name: string;
description: string;
source: 'workspace' | 'plugin' | 'skill' | 'builtin';
kind: string;
protocol: string | null;
endpoint?: string | null;
base_url?: string | null;
card_url?: string | null;
auth_env?: string | null;
auth_mode: string;
auth_audience?: string | null;
auth_scopes: string[];
tags: string[];
aliases: string[];
support_group: boolean;
support_streaming: boolean;
}
export interface UiMcpServerDescriptor {
id: string;
name: string;
transport: 'stdio' | 'http';
url?: string | null;
command?: string | null;
args?: string[];
auth_mode: string;
auth_audience?: string | null;
auth_scopes: string[];
headers?: Record<string, string>;
env?: Record<string, string>;
enabled: boolean;
tool_timeout?: number;
tool_count?: number;
tool_names?: string[];
status?: 'connected' | 'disconnected' | 'error';
last_error?: string | null;
}
export interface AuthzLocalBackendStatus {
backend_id?: string | null;
client_id?: string | null;
name?: string | null;
public_base_url?: string | null;
registered: boolean;
}
export interface AuthzChannelSettings {
configured: boolean;
config?: Record<string, unknown>;
secrets_masked?: boolean;
secret_keys?: string[];
updated_at?: string;
}
export interface AuthzStatus {
enabled: boolean;
base_url: string;
outlook_mcp_url?: string;
local_backend: AuthzLocalBackendStatus;
backend?: Record<string, unknown>;
permissions?: Record<string, unknown>;
outlook?: Record<string, unknown>;
channel_settings?: Record<string, AuthzChannelSettings>;
error?: string;
}
export interface AuthzBackendRecord {
backend_id: string;
name: string;
base_url: string;
frontend_base_url?: string | null;
status: string;
created_at: string;
updated_at: string;
is_local_backend?: boolean;
}
export interface AuthzRegisterBackendResponse {
backend_id: string;
client_id: string;
client_secret: string;
created_at: string;
saved_to_backend: boolean;
local_backend?: AuthzLocalBackendStatus & {
authz?: {
enabled: boolean;
base_url: string;
};
};
}
export interface OutlookMailboxAddress {
emailAddress?: {
name?: string | null;
address?: string | null;
} | null;
}
export interface OutlookFolderSummary {
id: string | null;
displayName: string | null;
parentFolderId?: string | null;
totalItemCount?: number | null;
unreadItemCount?: number | null;
}
export interface OutlookMessageSummary {
id: string | null;
changekey?: string | null;
subject: string | null;
from?: OutlookMailboxAddress | null;
receivedDateTime?: string | null;
isRead?: boolean;
bodyPreview?: string | null;
}
export interface OutlookMessageDetail extends OutlookMessageSummary {
body?: {
contentType: string;
content: string;
};
toRecipients?: OutlookMailboxAddress[];
ccRecipients?: OutlookMailboxAddress[];
}
export interface OutlookEventSummary {
id: string | null;
changekey?: string | null;
subject: string | null;
start?: {
dateTime: string;
timeZone: string;
} | null;
end?: {
dateTime: string;
timeZone: string;
} | null;
location?: {
displayName?: string | null;
} | null;
bodyPreview?: string | null;
attendees?: OutlookMailboxAddress[];
organizer?: OutlookMailboxAddress | null;
}
export interface OutlookDefaultsFields {
domain: string;
service_endpoint: string;
server: string;
default_timezone: string;
autodiscover: boolean;
}
export interface OutlookDefaults {
provider: string;
server_id: string;
mcp_command: string;
mcp_extra_args: string[];
fields: OutlookDefaultsFields;
}
export interface OutlookSavedConnection {
email: string;
username?: string | null;
domain?: string | null;
service_endpoint?: string | null;
server?: string | null;
autodiscover: boolean;
default_timezone: string;
}
export interface OutlookAuthStatus {
provider: string;
configured: boolean;
authenticated: boolean;
email?: string | null;
server?: string | null;
service_endpoint?: string | null;
autodiscover?: boolean;
username?: string | null;
}
export interface OutlookMeta {
updated_at?: string;
last_verified_at?: string;
last_connected_at?: string;
last_overview_refresh_at?: string;
[key: string]: unknown;
}
export interface OutlookStatus {
configured: boolean;
connected: boolean;
provider: string | null;
storage_mode?: string;
saved?: OutlookSavedConnection | null;
auth_status?: OutlookAuthStatus | null;
mcp_registered: boolean;
mcp_server_id: string;
defaults: OutlookDefaults;
meta: OutlookMeta;
error?: string | null;
}
export interface OutlookConnectionPayload {
email: string;
password: string;
username?: string;
domain?: string;
service_endpoint?: string;
server?: string;
autodiscover: boolean;
default_timezone: string;
}
export interface OutlookConnectionTestResult {
ok: boolean;
provider: string;
mailbox: string;
resolved_username: string;
resolved_domain?: string | null;
sample: {
folders: OutlookFolderSummary[];
inbox: OutlookMessageSummary[];
events: OutlookEventSummary[];
};
warnings?: string[];
}
export interface OutlookConnectResult {
ok: boolean;
probe: OutlookConnectionTestResult['sample'];
saved: {
config_file: string;
secrets_file: string;
state_dir: string;
};
mcp: {
id: string;
command: string;
args: string[];
sensitive: boolean;
tool_timeout: number;
};
meta: OutlookMeta;
}
export interface OutlookOverview {
mailbox: string;
timezone: string;
today: string;
connection: OutlookStatus;
recentInbox: OutlookMessageSummary[];
recentSent: OutlookMessageSummary[];
todayEvents: OutlookEventSummary[];
warnings?: string[];
meta: OutlookMeta;
}
export interface ProcessRun {
run_id: string;
parent_run_id?: string | null;
session_id?: string | null;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
title: string;
source?: string | null;
status: ProcessRunStatus;
started_at: string;
finished_at?: string | null;
summary?: string | null;
metadata?: Record<string, unknown>;
}
export interface ProcessEvent {
event_id: string;
run_id: string;
parent_run_id?: string | null;
kind: ProcessEventKind;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
text?: string;
status?: ProcessRunStatus;
message_role?: 'system' | 'user' | 'assistant' | 'tool';
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessArtifact {
artifact_id: string;
run_id: string;
actor_type: ProcessActorType;
actor_id: string;
actor_name?: string;
title: string;
artifact_type: 'text' | 'json' | 'file' | 'image' | 'link' | 'markdown';
content?: string;
data?: Record<string, unknown> | unknown[];
file_id?: string;
url?: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessRunStartedEvent {
type: 'process_run_started';
session_id?: string;
run_id: string;
parent_run_id?: string | null;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
source?: string | null;
title: string;
status: ProcessRunStatus;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessRunProgressEvent {
type: 'process_run_progress';
session_id?: string;
run_id: string;
parent_run_id?: string | null;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
text?: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessRunStatusEvent {
type: 'process_run_status';
session_id?: string;
run_id: string;
parent_run_id?: string | null;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
status: ProcessRunStatus;
text?: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessRunArtifactEvent {
type: 'process_run_artifact';
session_id?: string;
run_id: string;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
title: string;
artifact_type: ProcessArtifact['artifact_type'];
content?: string;
data?: Record<string, unknown> | unknown[];
file_id?: string;
url?: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessRunFinishedEvent {
type: 'process_run_finished';
session_id?: string;
run_id: string;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
status: ProcessRunStatus;
summary?: string | null;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcessRunCancelledEvent {
type: 'process_run_cancelled';
session_id?: string;
run_id: string;
actor_type: ProcessActorType;
actor_id: string;
actor_name: string;
status: 'cancelled';
created_at: string;
}
export interface ProcessCancelAckEvent {
type: 'process_cancel_ack';
run_id: string;
ok: boolean;
}
export interface ChatAssistantEvent {
type: 'message';
role: 'assistant';
content: string;
attachments?: FileAttachment[];
}
export interface ChatThinkingEvent {
type: 'status';
status: string;
}
export type ProcessWsEvent =
| ProcessRunStartedEvent
| ProcessRunProgressEvent
| ProcessRunStatusEvent
| ProcessRunArtifactEvent
| ProcessRunFinishedEvent
| ProcessRunCancelledEvent
| ProcessCancelAckEvent;
export type WsEvent = ChatAssistantEvent | ChatThinkingEvent | ProcessWsEvent;