chore: ARTEX 初始化

ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-07-26 04:27:45 -04:00
commit 0ef8561411
287 files changed
+70178

No files matched your search

+37
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
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
*.sh
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+3
View File
@@ -0,0 +1,3 @@
npm run generate:presets
git add src/lib/preferences/theme.ts
npm exec -- lint-staged
+96
View File
@@ -0,0 +1,96 @@
# AGENTS.md
## Project overview
Studio Admin is a responsive admin dashboard built with Next.js 16, React 19, TypeScript, Tailwind CSS v4, and shadcn/ui.
This repository uses the shadcn `radix-nova` style. The shadcn CLI reports `base: "radix"`, which refers to Radix UI. Always inspect the local components in `src/components/ui/` because individual wrappers may use different primitives.
<!-- BEGIN:nextjs-agent-rules -->
# Next.js: ALWAYS read docs before coding
Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.
<!-- END:nextjs-agent-rules -->
## shadcn skill
Use the shadcn skill for all work involving shadcn/ui components, styling, composition, registries, presets, or `components.json`.
If the skill is not available, install it with:
```bash
npx skills add shadcn/ui
```
The skill contains the component, styling, composition, accessibility, and CLI rules. Do not duplicate those rules here. Always inspect the local component source before using it.
Do not modify files inside `src/components/ui/` or `src/components/calendar/`. Keep these components intact and apply styling or customization where they are used.
## Setup
This project uses npm.
```bash
npm install
npm run dev
```
Available commands:
```bash
npm run build
npm run lint
npm run format
npm run check
npm run check:fix
npm run generate:presets
```
There is currently no automated test command. Run build, lint, check, or other validation commands only when the user explicitly requests that validation.
## Co-location-based structure
Keep feature code close to the route that owns it.
- Dashboard routes: `src/app/(main)/dashboard/<screen>/page.tsx`
- Screen-specific components, data, and schemas: `src/app/(main)/dashboard/<screen>/_components/`
- Shared dashboard components: `src/app/(main)/dashboard/_components/`
- Shared application components: `src/components/`
- Local shadcn components: `src/components/ui/`
- Shared hooks and utilities: `src/hooks/` and `src/lib/`
- Theme presets: `src/styles/presets/`
Keep a component inside its route until it is reused by another feature. Do not move screen-specific code into a shared directory preemptively.
## Creating or extending a screen
1. Inspect the closest current screen before writing code. Finance, Infrastructure, CRM, and Analytics are useful references. Do not use routes under `(legacy)` as references for new screens unless maintaining a legacy route.
2. When reproducing a UI from a screenshot or image, follow its visual direction closely, including layout, hierarchy, spacing, component structure, and important details. Implement it with the project's existing components and semantic theme tokens rather than copying raw color values. If the design needs a color that is not available through the existing theme tokens, or the user explicitly requests a non-theme color, use a named color from Tailwind's default palette. Do not use arbitrary hex, RGB, HSL, or OKLCH values.
3. Reuse the existing dashboard shell, local components, layout controls, and theme tokens.
4. Break each new page into focused components inside the route's `_components/` directory. Keep `page.tsx` small and focused on composing those pieces.
5. Keep `page.tsx` as a Server Component by default. Move interactive or browser-dependent code into a dedicated Client Component.
6. Add the screen to `src/navigation/sidebar/sidebar-items.ts` when it should appear in the dashboard navigation.
7. Decide the information hierarchy before choosing widgets. Let the content determine the page structure.
8. Keep the established visual rhythm where it fits: compact spacing, clear typography hierarchy, responsive action rows, and grids that collapse cleanly on smaller screens.
9. Widget selection is not a fixed formula. Try different arrangements of cards, resource rows, meters, charts, tabs, empty states, and actions, then keep the version that communicates the content clearly and feels consistent with the project.
10. Match nearby screens in card density, borders, radius, spacing, content width, and responsive behavior.
11. Use semantic theme tokens so new screens work with light mode, dark mode, and the existing theme presets.
12. Handle relevant loading, empty, error, disabled, and overflow states.
13. Keep screens accessible with semantic HTML, keyboard support, visible focus states, labels, and appropriate ARIA attributes.
## Code conventions
- TypeScript strict mode is enabled. Use precise types and avoid `any`.
- Use the existing `@/` import aliases.
- Follow the Biome configuration: double quotes, semicolons, two-space indentation, sorted imports, and a 120-character line width.
- Avoid unnecessary dependencies.
- Keep changes focused and do not refactor unrelated files.
## Contributions
- Use conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `docs:`, and `chore:`.
- Include screenshots for new screens and material visual changes. Include mobile and dark-theme states when relevant.
- Explain new reusable patterns or dependencies in the pull request.
- Follow `CONTRIBUTING.md` for the contribution workflow.
+127
View File
@@ -0,0 +1,127 @@
# Contributing to Studio Admin
Thanks for showing interest in improving **Studio Admin** (repo: `next-shadcn-admin-dashboard`).
This guide will help you set up your environment and understand how to contribute.
---
## Overview
This project is built with **Next.js 16**, **TypeScript**, **Tailwind CSS v4**, and **Shadcn UI**.
The goal is to keep the codebase modular, scalable, and easy to extend.
---
## Project Layout
We use a **colocation-based file system**. Each feature keeps its own pages, components, and logic.
```
src
├── app # Next.js routes (App Router)
│ ├── (auth) # Auth layouts & screens
│ ├── (main) # Main dashboard routes
│ │ └── (dashboard)
│ │ ├── crm
│ │ ├── finance
│ │ ├── default
│ │ └── ...
│ └── layout.tsx
├── components # Shared UI components
├── hooks # Reusable hooks
├── lib # Config & utilities
├── styles # Tailwind / theme setup
└── types # TypeScript definitions
```
If you’d like a more detailed example of this setup, check out the [Next Colocation Template](https://github.com/arhamkhnz/next-colocation-template), where the full structure is explained with examples.
---
## Getting Started
### Fork and Clone the Repository
1. Fork the Repository
Click [here](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/fork) to fork the repository.
2. Clone the Repository
```bash
git clone https://github.com/YOUR_USERNAME/next-shadcn-admin-dashboard.git
```
3. Navigate into the Project
```bash
cd next-shadcn-admin-dashboard
```
4. **Install dependencies**
```bash
npm install
```
5. **Run the dev server**
```bash
npm run dev
```
App will be available at [http://localhost:3000](http://localhost:3000).
---
## Contribution Flow
- Always create a new branch before working on changes:
```bash
git checkout -b feature/my-update
```
- Use clear commit messages:
```bash
git commit -m "feat: add finance dashboard screen"
```
- Open a Pull Request once ready.
- If your change adds a new UI screen or component, include a screenshot in your PR description.
---
## Where to Contribute
- **External Pages**: Landing pages or other non-dashboard routes → `src/app/(external)/`
- **Auth Screens**: Login, register, and authentication layouts → `src/app/(main)/auth/`
- **Dashboard Screens**: Feature dashboards like CRM, Finance, Analytics → `src/app/(main)/dashboard/`
- **Components**: Reusable UI goes in `src/components/`
- **Hooks**: Custom logic goes in `src/hooks/`
- **Themes**: New presets under `src/styles/presets/`
---
## Guidelines
- Prefer **TypeScript types** over `any`
- Husky pre-commit hooks are enabled - linting and formatting run automatically when you commit, and if there are errors the commit will be blocked until they are fixed.
- Follow **Shadcn UI** style & Tailwind v4 conventions
- Keep accessibility in mind (ARIA, keyboard nav)
- Use clear commit messages with conventional prefixes (`feat:`, `fix:`, `chore:`, etc.)
- Avoid unnecessary dependencies — prefer existing utilities where possible
---
## Submitting PRs
- Open a Pull Request once your changes are ready.
- Ensure your branch is up to date with `main` before submitting.
- Reference any related issue in your PR for context.
---
## Questions & Support
- Report bugs, suggestions, or issues via [GitHub Issues](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/issues)
---
Your contributions keep this project growing. 🚀
**Happy Vibe Coding!**
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Mohammed Arham Khan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+139
View File
@@ -0,0 +1,139 @@
# Next.js Admin Template with TypeScript & Shadcn UI
**Studio Admin** - Includes multiple dashboards, authentication layouts, customizable theme presets, and more.
<img src="https://github.com/arhamkhnz/next-shadcn-admin-dashboard/blob/main/media/dashboard.png?version=5" alt="Dashboard Screenshot">
Most admin templates I found, free or paid, felt cluttered, outdated, or too rigid. I built this as a cleaner alternative with features often missing in others, such as theme toggling and layout controls, while keeping the design modern, minimal, and flexible.
> **View demo:** [studio admin](https://next-shadcn-admin-dashboard.vercel.app)
> [!NOTE]
> Looking for the Base UI version? Check out [next-shadcn-admin-dashboard-baseui](https://github.com/arhamkhnz/next-shadcn-admin-dashboard-baseui).
> [!TIP]
> I’m also working on Nuxt.js, Svelte, and React (Vite + TanStack Router) versions of this dashboard. They’ll be live soon.
## Features
- Built with Next.js 16, TypeScript, Tailwind CSS v4, and Shadcn UI
- Responsive and mobile-friendly
- Customizable theme presets (light/dark modes with color schemes like Tangerine, Brutalist, and more)
- Flexible layouts (collapsible sidebar, variable content widths)
- Authentication flows and screens
- Prebuilt dashboards (Default, CRM, Finance, Analytics, Productivity) plus legacy variants
- Role-Based Access Control (RBAC) with config-driven UI and multi-tenant support *(planned)*
> [!NOTE]
> The default dashboard uses the **shadcn neutral** theme.
> It also includes additional color presets inspired by [Tweakcn](https://tweakcn.com):
>
> - Tangerine
> - Neo Brutalism
> - Soft Pop
>
> You can create more presets by following the same structure as the existing ones.
> Looking for the **Next.js 15** version?
> Check out the [`archive/next15`](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/tree/archive/next15) branch.
> This branch contains the setup prior to upgrading to Next 16 and the React Compiler.
> Looking for the **Next.js 14 + Tailwind CSS v3** version?
> Check out the [`archive/next14-tailwindv3`](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/tree/archive/next14-tailwindv3) branch.
> It has a different color theme and is not actively maintained, but I try to keep it updated with major changes.
## Tech Stack
- **Framework**: Next.js 16 (App Router), TypeScript, Tailwind CSS v4
- **UI Components**: Shadcn UI
- **Validation**: Zod
- **Forms & State Management**: React Hook Form, Zustand
- **Tables & Data Handling**: TanStack Table
- **Tooling & DX**: Biome, Husky
## Screens
### Available
- Default Dashboard
- CRM Dashboard
- Finance Dashboard
- Analytics Dashboard
- Productivity Dashboard
- E-commerce Dashboard
- Academy Dashboard
- Logistics Dashboard
- Infrastructure Dashboard
- Chat Page
- Email Page
- Users Management
- Roles Management
- Kanban Board
- Tasks Page
- Invoice Page
- Calendar Page
- Authentication (4 screens)
- Legacy: Default v1, CRM v1, Finance v1, Analytics v1
### Planned
I’ve added all the planned screens. Feel free to open an issue for requesting something specific.
## Colocation File System Architecture
This project follows a **colocation-based architecture** each feature keeps its own pages, components, and logic inside its route folder.
Shared UI, hooks, and configuration live at the top level, making the codebase modular, scalable, and easier to maintain as the app grows.
For a full breakdown of the structure with examples, see the [Next Colocation Template](https://github.com/arhamkhnz/next-colocation-template).
## Getting Started
You can run this project locally, or deploy it instantly with Vercel.
### Deploy with Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Farhamkhnz%2Fnext-shadcn-admin-dashboard)
_Deploy your own copy with one click._
### Run locally
1. **Clone the repository**
```bash
git clone https://github.com/arhamkhnz/next-shadcn-admin-dashboard.git
```
2. **Navigate into the project**
```bash
cd next-shadcn-admin-dashboard
```
3. **Install dependencies**
```bash
npm install
```
4. **Start the development server**
```bash
npm run dev
```
Your app will be running at [http://localhost:3000](http://localhost:3000)
### Formatting and Linting
Format, lint, and organize imports
```bash
npx @biomejs/biome check --write
```
> For more information on available rules, fixes, and CLI options, refer to the [Biome documentation](https://biomejs.dev/).
---
> [!IMPORTANT]
> This project is updated frequently. If you’re working from a fork or an older clone, pull the latest changes before syncing. Some updates may include breaking changes.
---
Contributions are welcome. Feel free to open issues, feature requests, or start a discussion.
**Happy Vibe Coding!**
+130
View File
@@ -0,0 +1,130 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
"react",
"react/**",
":BLANK_LINE:",
"next/**",
":BLANK_LINE:",
":PACKAGE:",
":BLANK_LINE:",
":ALIAS:",
":BLANK_LINE:",
":PATH:"
]
}
}
}
}
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!.github",
"!.husky",
"!node_modules",
"!.next",
"!dist",
"!out",
"!src/components/ui",
"!src/components/calendar"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 120,
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"expand": "auto",
"useEditorconfig": true
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUndeclaredVariables": "error",
"noNestedComponentDefinitions": "error",
"noUndeclaredDependencies": "error"
},
"nursery": {
"useSortedClasses": "on",
"useNullishCoalescing": "error",
"noFloatingPromises": "error",
"noMisusedPromises": "error"
},
"performance": {
"noImgElement": "warn"
},
"style": {
"noCommonJs": "error",
"noHeadElement": "warn",
"noNamespace": "error",
"useAsConstAssertion": "error",
"useSelfClosingElements": "error",
"noNestedTernary": "warn",
"noParameterAssign": "error",
"noInferrableTypes": "error",
"noUselessElse": "error",
"useFilenamingConvention": "error"
},
"suspicious": {
"noArrayIndexKey": "warn",
"noConstantBinaryExpressions": "error",
"noEmptyBlockStatements": "error",
"noUnknownAtRules": "off",
"noImportCycles": "error",
"noUnnecessaryConditions": "warn"
},
"complexity": {
"noImportantStyles": "off"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"html": {
"formatter": {
"indentScriptAndStyle": false,
"selfCloseVoidElements": "always"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+39
View File
@@ -0,0 +1,39 @@
/** @type {import('next').NextConfig} */
// 静态导出:`NEXT_EXPORT=1 next build` 产出纯静态目录到 web/out,可直接丢进
// nginx web 根目录运行。开发(next dev)不设该变量,保留 /api 反代与热更新。
const isExport = process.env.NEXT_EXPORT === "1";
// Vercel demo:整站走 mock,无后端,无需 /api 反代。
const isMock = process.env.NEXT_PUBLIC_MOCK === "1";
const nextConfig = {
reactCompiler: true,
// 允许从局域网 IP 访问 dev 资源(HMR),按需增删。
// dev 阶段放开任意 IPv4 来源访问 /_next/* 与 HMR(局域网 IP 变动也不受影响)。
// 注意:Next 出于安全禁止裸 "*",需用分段通配;"*.*.*.*" 匹配任意 IPv4。
allowedDevOrigins: ["*.*.*.*"],
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
...(isExport
? {
// 纯静态导出:无 Node 运行时;图片不经优化;每个路由产出 <route>/index.html。
output: "export",
images: { unoptimized: true },
trailingSlash: true,
}
: isMock
? {
// Vercel mock demo:无后端,不需要 /api 反代。
images: { unoptimized: true },
}
: {
// 开发:把 /api/* 反代到 Go 后端(默认 :8787,可用 AUTOPENTEST_API 覆盖)。
async rewrites() {
const backend = process.env.AUTOPENTEST_API ?? "http://localhost:8787";
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
},
}),
};
export default nextConfig;
+11120
View File
File diff suppressed because it is too large. Load diff
+81
View File
@@ -0,0 +1,81 @@
{
"name": "artex-web",
"version": "2.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:static": "NEXT_EXPORT=1 next build",
"start": "next start",
"lint": "biome lint",
"format": "biome format --write",
"check": "biome check",
"check:fix": "biome check --write",
"prepare": "husky",
"generate:presets": "ts-node -P tsconfig.scripts.json src/scripts/generate-theme-presets.ts"
},
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"biome check --write --no-errors-on-unmatched"
]
},
"dependencies": {
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@fullcalendar/react": "^7.0.0",
"@hookform/resolvers": "^5.4.0",
"@shadcn/react": "^0.1.0",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-geo": "^3.1.1",
"date-fns": "^4.4.0",
"embla-carousel-react": "^8.6.0",
"geist": "^1.7.2",
"input-otp": "^1.4.2",
"lucide-react": "^1.22.0",
"next": "^16.2.9",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.7",
"react-hook-form": "^7.80.0",
"react-is": "^19.2.7",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.0",
"recharts": "^3.8.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.12.0",
"simple-icons": "^16.24.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"temporal-polyfill": "^1.0.1",
"topojson-client": "^3.1.0",
"vaul": "^1.1.2",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@biomejs/biome": "^2.5.1",
"@tailwindcss/postcss": "^4.3.2",
"@types/d3-geo": "^3.1.0",
"@types/node": "^22.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"babel-plugin-react-compiler": "^1.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"postcss": "^8.5.16",
"tailwindcss": "^4.1.5",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080"><path fill="#fdfdfd" d="M0 0h1080v1080H0z"/><path fill="#6b34db" d="m391.25 285.75 2.104-.003c18.669.018 36.669 2.134 54.646 7.253l3.348.828c34.524 8.884 66.852 29.782 94.168 52.152a317 317 0 0 0 5.843 4.633c6.278 4.866 12.356 9.814 18.203 15.199 2.638 2.368 5.364 4.592 8.126 6.813 3.736 3.023 7.202 6.129 10.53 9.592 1.776 1.777 3.616 3.418 5.52 5.056 4.01 3.485 7.9 7.08 11.762 10.727l4.23 3.984 2.154 2.03q3.011 2.823 6.042 5.627c8.396 7.788 16.568 15.766 24.605 23.926 4.005 4.051 8.036 7.856 12.469 11.433a281 281 0 0 1 5 5l2.312 2.188C664 454 664 454 664 456l1.789.795c2.327 1.268 3.763 2.566 5.581 4.483l1.96 2.048 2.119 2.245 4.637 4.853 2.48 2.603c5.269 5.511 10.598 10.964 15.915 16.429 4.662 4.798 9.301 9.615 13.899 14.474a973 973 0 0 0 13.514 13.973 325 325 0 0 1 5.087 5.342c14.47 15.52 14.47 15.52 25.885 16.011 8.761-.193 17.186-1.402 25.134-5.256l2.703-1.25c22.559-11.344 37.824-31.091 45.97-54.7 3.597-11.1 4.508-21.338 4.515-32.925l.037-3.395c.036-20.761-7.343-39.562-20.225-55.73l-2.02-2.559c-10.263-12.205-27.064-24.128-43.285-25.777-30.302-.331-55.423 17.552-76.746 37.598-2.213 2.079-4.48 4.084-6.773 6.074-4.324 3.765-8.508 7.665-12.676 11.602l-2.145 2.013c-4.875 4.595-9.643 9.287-14.355 14.049-4.951-4.147-9.593-8.465-14.125-13.062l-1.801-1.815a2112 2112 0 0 1-3.332-3.365c-2.24-2.261-4.491-4.509-6.742-6.758l-4.148-4.148-2.365-2.365L592 365l2.892-3.381c.537-.628 1.073-1.256 1.627-1.902 4.96-5.748 10.57-10.529 16.481-15.276a292 292 0 0 0 7.544-6.384C648.12 314.169 681.294 296.655 717 289l3.277-.711c15.705-3.109 31.8-2.501 47.723-2.289l2.293.022c28.037.433 59.44 12.169 81.707 28.978l2.566 1.898c14.127 10.99 26.625 24.14 36.434 39.102l1.906 2.863c13.48 21.66 22.077 48.294 22.332 73.825l.03 2.46c.171 17.921.101 35.348-4.268 52.852l-.567 2.273c-6.162 23.584-18.357 45.395-34.183 63.789l-1.645 1.914c-2.985 3.398-6.021 6.244-9.605 9.024a637 637 0 0 0-5 5c-30.741 28.335-71.452 43.389-113 42-44.726-2.298-82.442-30.454-114.323-59.558a751 751 0 0 0-5.135-4.642c-11.057-10.022-21.535-20.641-32.073-31.201a6046 6046 0 0 0-8.401-8.397q-3.279-3.275-6.553-6.553a2260 2260 0 0 0-3.098-3.095c-8.977-8.961-8.977-8.961-12.879-13.516-4.738-5.522-9.949-10.593-15.097-15.729l-3.342-3.346q-3.486-3.488-6.977-6.972a6196 6196 0 0 1-8.879-8.882q-3.446-3.45-6.895-6.895-1.637-1.636-3.271-3.273C514.226 434.08 508.324 428.352 502 423q-1.678-1.459-3.352-2.922a527 527 0 0 0-22.7-18.564 998 998 0 0 1-6.409-5.034C461.23 389.934 452.893 383.734 444 378a881 881 0 0 1-3.469-2.352 373 373 0 0 0-26.292-16.354c-2.31-1.335-4.571-2.736-6.837-4.146-12.784-7.681-23.605-9.272-38.234-6.394-22.496 6.259-38.818 23.764-50.133 43.402C305.172 416.848 302.664 444.912 310 472c3.116 10.381 7.538 20.638 13 30l2 1 1.031-1.775a4360 4360 0 0 1 23.285-39.609 947 947 0 0 0 9.649-16.577c3.603-6.341 7.354-12.585 11.162-18.806a1832 1832 0 0 0 16.998-28.295c12.49-21.225 12.49-21.225 15.875-23.938 3.438 0 3.438 0 7 1 4.384 4.534 7.254 10.378 10.375 15.812l3.773 6.52 1.941 3.362c2.954 5.111 5.933 10.208 8.911 15.306l3.5 6 1.75 3 7 12 3.498 5.996q4.397 7.535 8.787 15.074a4694 4694 0 0 0 18.489 31.481c4.322 7.304 8.56 14.654 12.788 22.011 4.408 7.667 8.847 15.294 13.536 22.793 2.611 4.179 5.13 8.412 7.652 12.645 16.315-7.481 31.366-18.486 45.496-29.469l2.196-1.696a308 308 0 0 0 4.132-3.266c4.662-3.624 4.662-3.624 7.209-3.353 3.248 1.295 5.401 3.869 7.779 6.346l1.617 1.65a222 222 0 0 1 9.93 10.929c1.802 2.042 3.7 3.948 5.641 5.859 3.881 3.829 7.452 7.863 11 12q1.967 2.251 3.938 4.5L614 554c-11.23 16.515-27.545 29.058-44 40l-2.309 1.617C555.599 603.592 540.2 608.506 526 611l-2.824.551c-22.266 3.149-44.113-2.495-62.582-15.207-15.223-11.469-23.927-26.713-33.421-42.879a1072 1072 0 0 0-13.423-22.277l-1.589-2.565c-2.117-3.401-4.248-6.773-6.52-10.072C404 516 404 516 404 514h-2l-.664 2.461c-1.69 4.478-4.1 8.171-6.711 12.164q-1.65 2.566-3.293 5.137l-1.693 2.646c-2.51 3.969-4.923 7.994-7.327 12.03a3388 3388 0 0 1-15.91 26.366c-3.709 6.094-7.409 12.191-11.05 18.325l-1.666 2.803a1143 1143 0 0 0-3.02 5.117l-1.346 2.267-1.148 1.952c-1.251 1.848-2.449 3.309-4.172 4.732-4.7-.263-9.005-1.904-13.375-3.562l-2.097-.794c-7.777-3.021-15.178-6.71-22.528-10.644l-3.215-1.715C291.043 586.727 280.746 578.221 271 569l-2.488-2.328C256.722 555.303 248.1 542.123 240 528l-1.133-1.947C220.79 494.513 214.286 449.494 222 414l.489-2.359C227.531 387.559 237.036 366.538 252 347l2.168-2.891L256 342h2l.785-1.77c6.935-12.731 23.26-21.621 35.215-29.23l2.957-1.906c16.635-10.237 35.078-16.036 54.105-19.782l3.3-.651c12.283-2.317 24.404-2.898 36.888-2.911"/><path fill="#393bd1" d="m311 330 1 2 2.177-.476C329.387 328.7 349 327.855 363 335c2.168.336 2.168.336 4.188.375 5.721.468 10.944 2.617 15.812 5.625l1 3 5 1v1l-3.043.297c-22.61 2.393-40.542 11.337-55.543 28.586-17.45 22.532-27.681 50.862-24.109 79.566 2.553 16.784 8.126 32.865 16.695 47.551l2 1 1.031-1.775a4360 4360 0 0 1 23.285-39.609 947 947 0 0 0 9.649-16.577c3.603-6.341 7.354-12.585 11.162-18.806a18Line truncated

After

Width:  |  Height:  |  Size: 62 KiB

+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
return <>{children}</>;
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { auth } from "@/lib/auth";
export default function LoginPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
useEffect(() => {
// 已登录直接进主界面(静态导出下无 middleware 代劳这层跳转)。
if (auth.getToken()) {
router.replace("/function/tasks");
return;
}
api.authStatus()
.then(({ initialized }) => {
if (!initialized) router.replace("/setup");
})
.catch(() => setError("无法连接到后端服务"))
.finally(() => setChecking(false));
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const { token } = await api.login("ARTEX", password);
auth.setToken(token);
router.replace("/function/tasks");
} catch {
setError("用户名或密码错误");
} finally {
setLoading(false);
}
}
if (checking) return null;
return (
<div className="flex h-dvh">
{/* Left panel */}
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
<div className="relative flex items-center justify-center">
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="ARTEX"
width={160}
height={160}
className="relative brightness-0 invert"
/>
</div>
</div>
{/* Right panel */}
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
<div className="space-y-4 text-center">
<h2 className="text-2xl font-medium tracking-tight">登录</h2>
<p className="mx-auto max-w-xl text-muted-foreground">欢迎回来,请输入密码以继续使用 ARTEX</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input id="username" value="ARTEX" readOnly className="bg-muted text-muted-foreground" />
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoFocus
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { auth } from "@/lib/auth";
export default function SetupPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
useEffect(() => {
api.authStatus()
.then(({ initialized }) => {
if (initialized) router.replace("/login");
})
.catch(() => setError("无法连接到后端服务"))
.finally(() => setChecking(false));
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirm) {
setError("两次输入的密码不一致");
return;
}
if (password.length < 8) {
setError("密码长度至少 8 位");
return;
}
setLoading(true);
setError("");
try {
const { token } = await api.initPassword(password);
auth.setToken(token);
router.replace("/function/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "初始化失败");
} finally {
setLoading(false);
}
}
if (checking) return null;
return (
<div className="flex h-dvh">
{/* Left panel */}
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
<div className="relative flex items-center justify-center">
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="ARTEX"
width={160}
height={160}
className="relative brightness-0 invert"
/>
</div>
</div>
{/* Right panel */}
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
<div className="space-y-4 text-center">
<h2 className="text-2xl font-medium tracking-tight">初始化密码</h2>
<p className="mx-auto max-w-xl text-muted-foreground">首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="password">新密码</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 8 位"
autoFocus
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">确认密码</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入密码"
autoComplete="new-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
{loading ? "保存中..." : "设置密码并登录"}
</Button>
</form>
</div>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/function/tasks");
return <>Coming Soon</>;
}
@@ -0,0 +1,66 @@
"use client";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { useCurrentUser } from "@/hooks/use-current-user";
import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) {
const p = (() => {
try {
return decodeURIComponent(pathname);
} catch {
return pathname;
}
})();
return p.startsWith("/function/tasks/");
}
export function MainContent({ children }: { children: ReactNode }) {
const currentUser = useCurrentUser();
const pathname = usePathname();
if (isFullBleed(pathname)) {
return <>{children}</>;
}
return (
<>
<header
className={cn(
"flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
"[html[data-navbar-style=sticky]_&]:sticky [html[data-navbar-style=sticky]_&]:top-0 [html[data-navbar-style=sticky]_&]:z-50 [html[data-navbar-style=sticky]_&]:overflow-hidden [html[data-navbar-style=sticky]_&]:rounded-t-[inherit] [html[data-navbar-style=sticky]_&]:bg-background/50 [html[data-navbar-style=sticky]_&]:backdrop-blur-md",
)}
>
<div className="flex w-full items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-1 lg:gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center"
/>
<SearchDialog />
</div>
<div className="flex items-center gap-2">
<LayoutControls />
<ThemeSwitcher />
<AccountSwitcher users={[currentUser]} />
</div>
</div>
</header>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-4 has-data-[content-padding=false]:p-0 md:p-6 md:has-data-[content-padding=false]:p-0">
{children}
</div>
</>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { Check, KeyRound, LogOut } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { auth } from "@/lib/auth";
import { cn, getInitials } from "@/lib/utils";
import { ChangePasswordDialog } from "./change-password-dialog";
export function AccountSwitcher({
users,
}: {
readonly users: ReadonlyArray<{
readonly id: string;
readonly name: string;
readonly email: string;
readonly avatar: string;
readonly role: string;
}>;
}) {
const [activeUser, setActiveUser] = useState(users[0]);
const [pwOpen, setPwOpen] = useState(false);
function handleLogout() {
auth.clearToken();
window.location.href = "/login";
}
if (!activeUser) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="size-8 rounded-lg">
<AvatarImage src={activeUser.avatar || undefined} alt={activeUser.name} />
<AvatarFallback>{getInitials(activeUser.name)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56 space-y-1 rounded-lg" side="bottom" align="end" sideOffset={4}>
{users.map((user) => (
<DropdownMenuItem
key={user.email}
className={cn("p-0", user.id === activeUser.id && "bg-accent/50")}
aria-current={user.id === activeUser.id ? "true" : undefined}
onClick={() => setActiveUser(user)}
>
<div className="flex w-full items-center gap-2 px-1 py-1.5">
<Avatar className="size-9 rounded-lg">
<AvatarImage src={user.avatar || undefined} alt={user.name} />
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs capitalize">{user.role}</span>
</div>
<span
className={cn(
"mr-1 flex size-5 items-center justify-center rounded-full text-primary opacity-0",
user.id === activeUser.id && "opacity-100",
)}
>
<Check aria-hidden="true" />
</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPwOpen(true)}>
<KeyRound />
修改密码
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
</>
);
}
@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { useShallow } from "zustand/react/shallow";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { APP_CONFIG } from "@/config/app-config";
import { useCurrentUser } from "@/hooks/use-current-user";
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
import { NavMain } from "./nav-main";
import { NavUser } from "./nav-user";
import { SidebarSupportCard } from "./sidebar-support-card";
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const currentUser = useCurrentUser();
const { sidebarVariant, sidebarCollapsible, isSynced } = usePreferencesStore(
useShallow((s) => ({
sidebarVariant: s.sidebarVariant,
sidebarCollapsible: s.sidebarCollapsible,
isSynced: s.isSynced,
})),
);
const variant = isSynced ? sidebarVariant : props.variant;
const collapsible = isSynced ? sidebarCollapsible : props.collapsible;
return (
<Sidebar {...props} variant={variant} collapsible={collapsible}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link prefetch={false} href="/function/tasks">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.png" alt="ARTEX" width={40} height={40} className="shrink-0" />
<span className="font-semibold text-base">{APP_CONFIG.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={sidebarItems} />
{/* <NavDocuments items={data.documents} /> */}
{/* <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
</SidebarContent>
<SidebarFooter>
<NavUser user={currentUser} />
</SidebarFooter>
</Sidebar>
);
}
@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
export function ChangePasswordDialog({
open,
onOpenChange,
}: {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}) {
const [oldPassword, setOldPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [saving, setSaving] = React.useState(false);
// reset fields whenever the dialog closes
React.useEffect(() => {
if (!open) {
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
}
}, [open]);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!oldPassword || !newPassword) {
toast.error("请填写当前密码和新密码");
return;
}
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致");
return;
}
setSaving(true);
api
.changePassword(oldPassword, newPassword)
.then(() => {
toast.success("密码已修改");
onOpenChange(false);
})
.catch((err) => toast.error(`修改失败:${(err as Error).message}`))
.finally(() => setSaving(false));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>修改密码</DialogTitle>
<DialogDescription>
用户名固定为 <b>ARTEX</b>。需先输入当前密码验证;修改后已签发的登录 token 仍有效直至过期。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-old">当前密码</Label>
<Input
id="cp-old"
type="password"
autoComplete="current-password"
value={oldPassword}
disabled={saving}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-new">新密码</Label>
<Input
id="cp-new"
type="password"
autoComplete="new-password"
value={newPassword}
disabled={saving}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-confirm">确认新密码</Label>
<Input
id="cp-confirm"
type="password"
autoComplete="new-password"
value={confirmPassword}
disabled={saving}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
取消
</Button>
<Button type="submit" disabled={saving}>
{saving ? "修改中…" : "确认修改"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,265 @@
"use client";
import { Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { type FontKey, fontOptions } from "@/lib/fonts/registry";
import type { ContentLayout, NavbarStyle, SidebarCollapsible, SidebarVariant } from "@/lib/preferences/layout";
import {
applyContentLayout,
applyFont,
applyNavbarStyle,
applySidebarCollapsible,
applySidebarVariant,
} from "@/lib/preferences/layout-utils";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { persistPreference } from "@/lib/preferences/preferences-storage";
import { THEME_PRESET_OPTIONS, type ThemeMode, type ThemePreset } from "@/lib/preferences/theme";
import { applyThemePreset } from "@/lib/preferences/theme-utils";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
export function LayoutControls() {
const themeMode = usePreferencesStore((s) => s.themeMode);
const resolvedThemeMode = usePreferencesStore((s) => s.resolvedThemeMode);
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
const themePreset = usePreferencesStore((s) => s.themePreset);
const setThemePreset = usePreferencesStore((s) => s.setThemePreset);
const contentLayout = usePreferencesStore((s) => s.contentLayout);
const setContentLayout = usePreferencesStore((s) => s.setContentLayout);
const navbarStyle = usePreferencesStore((s) => s.navbarStyle);
const setNavbarStyle = usePreferencesStore((s) => s.setNavbarStyle);
const variant = usePreferencesStore((s) => s.sidebarVariant);
const setSidebarVariant = usePreferencesStore((s) => s.setSidebarVariant);
const collapsible = usePreferencesStore((s) => s.sidebarCollapsible);
const setSidebarCollapsible = usePreferencesStore((s) => s.setSidebarCollapsible);
const font = usePreferencesStore((s) => s.font);
const setFont = usePreferencesStore((s) => s.setFont);
const onThemePresetChange = (preset: ThemePreset) => {
applyThemePreset(preset);
setThemePreset(preset);
void persistPreference("theme_preset", preset);
};
const onThemeModeChange = (mode: ThemeMode | "") => {
if (!mode) return;
setThemeMode(mode);
void persistPreference("theme_mode", mode);
};
const onContentLayoutChange = (layout: ContentLayout | "") => {
if (!layout) return;
applyContentLayout(layout);
setContentLayout(layout);
void persistPreference("content_layout", layout);
};
const onNavbarStyleChange = (style: NavbarStyle | "") => {
if (!style) return;
applyNavbarStyle(style);
setNavbarStyle(style);
void persistPreference("navbar_style", style);
};
const onSidebarStyleChange = (value: SidebarVariant | "") => {
if (!value) return;
setSidebarVariant(value);
applySidebarVariant(value);
void persistPreference("sidebar_variant", value);
};
const onSidebarCollapseModeChange = (value: SidebarCollapsible | "") => {
if (!value) return;
setSidebarCollapsible(value);
applySidebarCollapsible(value);
void persistPreference("sidebar_collapsible", value);
};
const onFontChange = (value: FontKey | "") => {
if (!value) return;
applyFont(value);
setFont(value);
void persistPreference("font", value);
};
const handleRestore = () => {
onThemePresetChange(PREFERENCE_DEFAULTS.theme_preset);
onThemeModeChange(PREFERENCE_DEFAULTS.theme_mode);
onContentLayoutChange(PREFERENCE_DEFAULTS.content_layout);
onNavbarStyleChange(PREFERENCE_DEFAULTS.navbar_style);
onSidebarStyleChange(PREFERENCE_DEFAULTS.sidebar_variant);
onSidebarCollapseModeChange(PREFERENCE_DEFAULTS.sidebar_collapsible);
onFontChange(PREFERENCE_DEFAULTS.font);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button size="icon">
<Settings />
</Button>
</PopoverTrigger>
<PopoverContent align="end">
<div className="flex flex-col gap-5">
<div className="space-y-1.5">
<h4 className="font-medium text-sm leading-none">Preferences</h4>
<p className="text-muted-foreground text-xs">Customize your dashboard layout preferences.</p>
</div>
<div className="space-y-3 **:data-[slot=toggle-group]:w-full **:data-[slot=toggle-group-item]:flex-1 **:data-[slot=toggle-group-item]:text-xs">
<div className="space-y-1">
<Label className="font-medium text-xs">Theme Preset</Label>
<Select value={themePreset} onValueChange={onThemePresetChange}>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectValue placeholder="Preset" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{THEME_PRESET_OPTIONS.map((preset) => (
<SelectItem key={preset.value} className="text-xs" value={preset.value}>
<span
className="size-2.5 rounded-full"
style={{
backgroundColor:
(resolvedThemeMode ?? "light") === "dark" ? preset.primary.dark : preset.primary.light,
}}
/>
{preset.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Fonts</Label>
<Select value={font} onValueChange={onFontChange}>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectValue placeholder="Select font" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{fontOptions.map((font) => (
<SelectItem key={font.key} className="text-xs" value={font.key}>
{font.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Theme Mode</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={themeMode}
onValueChange={onThemeModeChange}
>
<ToggleGroupItem value="light" aria-label="Toggle light">
Light
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Toggle dark">
Dark
</ToggleGroupItem>
<ToggleGroupItem value="system" aria-label="Toggle system">
System
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Page Layout</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={contentLayout}
onValueChange={onContentLayoutChange}
>
<ToggleGroupItem value="centered" aria-label="Toggle centered">
Centered
</ToggleGroupItem>
<ToggleGroupItem value="full-width" aria-label="Toggle full-width">
Full Width
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Navbar Behavior</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={navbarStyle}
onValueChange={onNavbarStyleChange}
>
<ToggleGroupItem value="sticky" aria-label="Toggle sticky">
Sticky
</ToggleGroupItem>
<ToggleGroupItem value="scroll" aria-label="Toggle scroll">
Scroll
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Sidebar Style</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={variant}
onValueChange={onSidebarStyleChange}
>
<ToggleGroupItem value="inset" aria-label="Toggle inset">
Inset
</ToggleGroupItem>
<ToggleGroupItem value="sidebar" aria-label="Toggle sidebar">
Sidebar
</ToggleGroupItem>
<ToggleGroupItem value="floating" aria-label="Toggle floating">
Floating
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Sidebar Collapse Mode</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={collapsible}
onValueChange={onSidebarCollapseModeChange}
>
<ToggleGroupItem value="icon" aria-label="Toggle icon">
Icon
</ToggleGroupItem>
<ToggleGroupItem value="offcanvas" aria-label="Toggle offcanvas">
OffCanvas
</ToggleGroupItem>
</ToggleGroup>
</div>
<Button type="button" size="sm" variant="outline" className="w-full text-xs" onClick={handleRestore}>
Restore Defaults
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,83 @@
"use client";
import { Ellipsis, Folder, Forward, type LucideIcon, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavDocuments({
items,
}: {
readonly items: readonly {
readonly name: string;
readonly url: string;
readonly icon: LucideIcon;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover className="rounded-sm data-[state=open]:bg-accent">
<Ellipsis />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2 />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<Ellipsis className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
@@ -0,0 +1,326 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight, MailIcon, PlusCircleIcon } from "lucide-react";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import type {
NavBadge,
NavGroup,
NavMainItem,
NavMainLinkItem,
NavMainParentItem,
} from "@/navigation/sidebar/sidebar-items";
interface NavMainProps {
readonly items: readonly NavGroup[];
}
interface NavItemProps {
readonly item: NavMainItem;
readonly isItemActive: (item: NavMainItem) => boolean;
readonly isSubItemActive: (url: string) => boolean;
readonly isSubmenuOpen: (item: NavMainParentItem) => boolean;
}
interface NavLinkItemProps {
readonly item: NavMainLinkItem;
readonly isActive: boolean;
readonly showIconFallback: boolean;
}
interface NavLinkIconProps {
readonly item: NavMainLinkItem;
readonly showFallback: boolean;
}
interface NavDropdownItemProps {
readonly item: NavMainParentItem;
readonly isActive: boolean;
readonly isSubItemActive: (url: string) => boolean;
}
interface NavCollapsibleItemProps {
readonly item: NavMainParentItem;
readonly isActive: boolean;
readonly defaultOpen: boolean;
readonly isSubItemActive: (url: string) => boolean;
}
function CollapsedIconFallback({ title }: { title: string }) {
return (
<span className="flex size-4 shrink-0 items-center justify-center rounded-xs font-medium text-[10px] outline">
{title.slice(0, 1)}
</span>
);
}
function hasSubItems(item: NavMainItem): item is NavMainParentItem {
return Boolean(item.subItems?.length);
}
export function NavMain({ items }: NavMainProps) {
const rawPath = usePathname();
// 路由段含中文,pathname 可能是百分号编码,解码后再与导航 url 比较,保证高亮命中。
const path = (() => {
try {
return decodeURIComponent(rawPath);
} catch {
return rawPath;
}
})();
const isItemActive = (item: NavMainItem) => {
if (hasSubItems(item)) {
return item.subItems.some((sub) => path.startsWith(sub.url));
}
return path === item.url;
};
const isSubItemActive = (url: string) => {
return path === url;
};
const isSubmenuOpen = (item: NavMainParentItem) => {
return item.subItems.some((sub) => path.startsWith(sub.url));
};
return (
<>
{items.map((group) => (
<SidebarGroup key={group.id}>
{group.label && (
<SidebarGroupLabel className="group-data-[collapsible=icon]:pointer-events-none">
{group.label}
</SidebarGroupLabel>
)}
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<NavItem
key={item.id}
item={item}
isItemActive={isItemActive}
isSubItemActive={isSubItemActive}
isSubmenuOpen={isSubmenuOpen}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
);
}
function NavItem({ item, isItemActive, isSubItemActive, isSubmenuOpen }: NavItemProps) {
const { state, isMobile } = useSidebar();
const isCollapsedDesktop = state === "collapsed" && !isMobile;
if (!hasSubItems(item)) {
return <NavLinkItem item={item} isActive={isItemActive(item)} showIconFallback={isCollapsedDesktop} />;
}
if (isCollapsedDesktop) {
return <NavDropdownItem item={item} isActive={isItemActive(item)} isSubItemActive={isSubItemActive} />;
}
return (
<NavCollapsibleItem
item={item}
isActive={isItemActive(item)}
defaultOpen={isSubmenuOpen(item)}
isSubItemActive={isSubItemActive}
/>
);
}
// InterceptPendingBadge polls /api/intercept/pending every 5 s and shows a red
// count badge when there are unresolved approval requests.
function InterceptPendingBadge() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
let live = true;
async function poll() {
try {
const list = await api.interceptPending();
if (live) setCount(list.length);
} catch { /* ignore */ }
}
poll();
const t = setInterval(poll, 5000);
return () => { live = false; clearInterval(t); };
}, []);
if (count === 0) return null;
return (
<SidebarMenuBadge className="flex h-4.5 min-w-[18px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-1 ring-amber-400/40">
{count > 99 ? "99+" : count}
</SidebarMenuBadge>
);
}
function NavLinkItem({ item, isActive, showIconFallback }: NavLinkItemProps) {
return (
<SidebarMenuItem>
<SidebarMenuButton asChild aria-disabled={item.disabled} tooltip={item.title} isActive={isActive}>
<Link
prefetch={false}
href={item.url}
target={item.newTab ? "_blank" : undefined}
rel={item.newTab ? "noreferrer" : undefined}
>
<NavLinkIcon item={item} showFallback={showIconFallback} />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{item.id === "approvals" ? <InterceptPendingBadge /> : <NavItemBadge badge={item.badge} />}
</SidebarMenuItem>
);
}
function NavLinkIcon({ item, showFallback }: NavLinkIconProps) {
const Icon = item.icon;
if (Icon) {
return <Icon />;
}
if (showFallback) {
return <CollapsedIconFallback title={item.title} />;
}
return null;
}
function NavDropdownItem({ item, isActive, isSubItemActive }: NavDropdownItemProps) {
const Icon = item.icon;
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isActive} disabled={item.disabled}>
{Icon ? <Icon /> : <CollapsedIconFallback title={item.title} />}
<span>{item.title}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" sideOffset={12} className="w-48">
<DropdownMenuGroup>
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
return (
<DropdownMenuItem key={subItem.id} asChild disabled={subItem.disabled}>
<Link
prefetch={false}
href={subItem.url}
target={subItem.newTab ? "_blank" : undefined}
rel={subItem.newTab ? "noreferrer" : undefined}
aria-current={isSubItemActive(subItem.url) ? "page" : undefined}
className="flex items-center gap-2"
>
{SubIcon && <SubIcon />}
<span>{subItem.title}</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}
function NavCollapsibleItem({ item, isActive, defaultOpen, isSubItemActive }: NavCollapsibleItemProps) {
const Icon = item.icon;
return (
<Collapsible asChild defaultOpen={defaultOpen} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isActive} disabled={item.disabled}>
{Icon && <Icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<NavItemBadge badge={item.badge} />
<CollapsibleContent>
<SidebarMenuSub>
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
return (
<SidebarMenuSubItem key={subItem.id}>
<SidebarMenuSubButton
asChild
aria-disabled={subItem.disabled}
isActive={isSubItemActive(subItem.url)}
>
<Link
prefetch={false}
href={subItem.url}
target={subItem.newTab ? "_blank" : undefined}
rel={subItem.newTab ? "noreferrer" : undefined}
>
{SubIcon && <SubIcon />}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
function NavItemBadge({ badge }: { badge?: NavBadge }) {
if (!badge) {
return null;
}
return (
<SidebarMenuBadge
className={cn(
"rounded-sm border capitalize",
badge === "new" &&
"border-green-600 text-green-600 peer-hover/menu-button:text-green-600 peer-data-active/menu-button:text-green-600",
badge === "soon" && "border-muted-foreground text-muted-foreground",
)}
>
{badge}
</SidebarMenuBadge>
);
}
@@ -0,0 +1,43 @@
"use client";
import type * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import { EllipsisVertical, KeyRound, LogOut } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { getInitials } from "@/lib/utils";
import { ChangePasswordDialog } from "./change-password-dialog";
export function NavUser({
user,
}: {
readonly user: {
readonly name: string;
readonly email: string;
readonly avatar: string;
};
}) {
const { isMobile } = useSidebar();
const [pwOpen, setPwOpen] = React.useState(false);
function handleLogout() {
auth.clearToken();
// 硬跳转:让浏览器用已清除的 cookie 发起全新请求,
// middleware 才能正确读到空 token 并放行 /login
window.location.href = "/login";
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar || undefined} alt={user.name} />
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
</div>
<EllipsisVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar || undefined} alt={user.name} />
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPwOpen(true)}>
<KeyRound />
修改密码
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
</SidebarMenu>
);
}
@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import type { NavMainItem } from "@/navigation/sidebar/sidebar-items";
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
type SearchItem = {
id: string;
group: string;
label: string;
url: string;
icon?: NavMainItem["icon"];
disabled?: boolean;
newTab?: boolean;
};
const sidebarGroupLabels = new Set(sidebarItems.flatMap((group) => (group.label ? [group.label] : [])));
function getSubItemGroup(groupLabel: string | undefined, itemTitle: string) {
return sidebarGroupLabels.has(itemTitle) ? (groupLabel ?? "Other") : itemTitle;
}
const searchItems: SearchItem[] = sidebarItems.flatMap((group) =>
group.items.flatMap((item) => {
if (item.subItems) {
return item.subItems.map((sub) => ({
id: sub.id,
group: getSubItemGroup(group.label, item.title),
label: sub.title,
url: sub.url,
icon: item.icon,
disabled: sub.disabled,
newTab: sub.newTab,
}));
}
return [
{
id: item.id,
group: group.label ?? "Other",
label: item.title,
url: item.url,
icon: item.icon,
disabled: item.disabled,
newTab: item.newTab,
},
];
}),
);
function getAvailableItems(items: SearchItem[]) {
return items.filter((item) => !item.disabled && !item.url.includes("coming-soon"));
}
const recommendations = getAvailableItems(searchItems);
function groupBy(items: SearchItem[]) {
const groups = [...new Set(items.map((item) => item.group))];
return groups.map((group) => ({
group,
items: items.filter((item) => item.group === group),
}));
}
export function SearchDialog() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const router = useRouter();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) setQuery("");
};
const handleSelect = (item: SearchItem) => {
if (item.disabled) return;
handleOpenChange(false);
if (item.newTab) {
window.open(item.url, "_blank", "noopener,noreferrer");
} else {
router.push(item.url);
}
};
const renderGroups = (items: SearchItem[]) =>
groupBy(items).map(({ group, items: groupItems }, index) => (
<React.Fragment key={group}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={group}>
{groupItems.map((item) => (
<CommandItem
disabled={item.disabled}
key={`${group}-${item.id}`}
value={`${item.group} ${item.label}`}
onSelect={() => handleSelect(item)}
>
<span className="flex min-w-0 items-center gap-2">
{item.icon && <item.icon />}
<span className="truncate">{item.label}</span>
</span>
</CommandItem>
))}
</CommandGroup>
</React.Fragment>
));
return (
<>
<Button
onClick={() => handleOpenChange(true)}
variant="link"
className="px-0! font-normal text-muted-foreground hover:no-underline"
>
<Search data-icon="inline-start" />
Search
<kbd className="inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium text-[10px]">
<span className="text-xs">⌘</span>J
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={handleOpenChange}>
<Command>
<CommandInput placeholder="Search dashboards, users, and more…" value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{query ? renderGroups(searchItems) : renderGroups(recommendations)}
</CommandList>
</Command>
</CommandDialog>
</>
);
}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { siX } from "simple-icons";
import { SimpleIcon } from "@/components/simple-icon";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function SidebarSupportCard() {
return (
<Card size="sm" className="overflow-hidden shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="min-w-0 px-4">
<CardTitle className="truncate text-sm">Looking for something more?</CardTitle>
<CardDescription className="line-clamp-2">
Open an issue or do reach out to me on&nbsp;
<Link
href="https://x.com/arhamkhnz"
target="_blank"
rel="noreferrer"
aria-label="Reach out on X"
className="inline-flex items-center text-foreground"
>
<SimpleIcon icon={siX} aria-hidden className="size-3 fill-current" />
</Link>
.
</CardDescription>
</CardHeader>
</Card>
);
}
@@ -0,0 +1,35 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { persistPreference } from "@/lib/preferences/preferences-storage";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
const THEME_CYCLE = ["light", "dark", "system"] as const;
export function ThemeSwitcher() {
const themeMode = usePreferencesStore((s) => s.themeMode);
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
const cycleTheme = () => {
const currentIndex = THEME_CYCLE.indexOf(themeMode);
const nextTheme = THEME_CYCLE[(currentIndex + 1) % THEME_CYCLE.length];
setThemeMode(nextTheme);
void persistPreference("theme_mode", nextTheme);
};
return (
<Button size="icon" onClick={cycleTheme} aria-label={`Current theme: ${themeMode}. Click to cycle themes`}>
{/* SYSTEM */}
<Monitor className="hidden [html[data-theme-mode=system]_&]:block" />
{/* DARK (resolved) */}
<Sun className="hidden dark:block [html[data-theme-mode=system]_&]:hidden" />
{/* LIGHT (resolved) */}
<Moon className="block dark:hidden [html[data-theme-mode=system]_&]:hidden" />
</Button>
);
}
+740
View File
@@ -0,0 +1,740 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Bot, ChevronDownIcon, PlusIcon, SendIcon, Square, Trash2Icon, ZapIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { api } from "@/lib/api";
import type { Activity, Agent, Conversation, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
// console — shown while a turn is streaming.
function LiveBadge() {
return (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
);
}
// Composer is the shared bottom input (textarea grows to a cap, Enter sends,
// Shift+Enter newlines) — the same affordance across DraftChat and ChatView.
function Composer({
value,
onChange,
onSend,
disabled,
placeholder,
leftSlot,
running,
onStop,
stopDisabled,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
disabled: boolean;
placeholder: string;
leftSlot?: React.ReactNode;
running?: boolean;
onStop?: () => void;
stopDisabled?: boolean;
}) {
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
}
return (
<div className="border-t p-3">
<div className="flex items-end gap-2">
{leftSlot}
<Textarea
className="max-h-40 min-h-10 flex-1 resize-none"
rows={1}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{running ? (
// while a run is in flight the send button becomes a stop button —
// aborts just this session (the trigger queue keeps going).
<Button size="icon" variant="destructive" onClick={onStop} disabled={stopDisabled} title="停止本次运行">
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button size="icon" onClick={onSend} disabled={disabled || !value.trim()}>
<SendIcon className="size-4" />
</Button>
)}
</div>
</div>
);
}
// LLMProfileRow shows the active LLM config below the composer and lets the user
// switch it via a Popover. `selected` is the profile id or null for default.
function LLMProfileRow({
profiles,
selected,
onChange,
disabled,
rightSlot,
}: {
profiles: LLMProfile[];
selected: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
rightSlot?: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const activeDefault = profiles.find((p) => p.is_default);
const current = selected != null ? profiles.find((p) => Number(p.id) === selected) : null;
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
return (
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
<span className="text-muted-foreground/70 text-xs">{label}</span>
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="text-primary flex items-center gap-0.5 text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
>
更换
<ChevronDownIcon className="size-3" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<p className="text-muted-foreground px-2 py-1 text-[11px] font-medium">选择 LLM 配置</p>
{/* default option */}
<button
type="button"
onClick={() => { onChange(null); setOpen(false); }}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected == null && "bg-accent",
)}
>
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
{activeDefault && (
<span className="text-muted-foreground text-[11px]">{activeDefault.format} · {activeDefault.model}</span>
)}
</button>
{profiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { onChange(Number(p.id)); setOpen(false); }}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected === Number(p.id) && "bg-accent",
)}
>
<span className="text-sm">{p.name}</span>
<span className="text-muted-foreground text-[11px]">{p.format} · {p.model}</span>
</button>
))}
</PopoverContent>
</Popover>
{rightSlot}
</div>
);
}
// DraftChat is the default right-pane view: a fresh chat (agent picker in the
// header, centered empty state, composer) with NO conversation created yet. The
// conversation is created lazily on the first send (ChatGPT-style), then the
// parent switches to the real ChatView.
function DraftChat({
agents,
profiles,
onStarted,
}: {
agents: Agent[];
profiles: LLMProfile[];
onStarted: (c: Conversation) => void;
}) {
const [agentKey, setAgentKey] = React.useState("");
const [llmProfileId, setLlmProfileId] = React.useState<number | null>(null);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
// default the agent to Auto once agents load.
React.useEffect(() => {
if (!agentKey && agents.some((a) => a.key === "auto")) setAgentKey("auto");
}, [agentKey, agents]);
const agent = agents.find((a) => a.key === agentKey);
async function send() {
const msg = input.trim();
if (!msg || !agentKey || sending) return;
setSending(true);
try {
const c = await api.createConversation(agentKey, "", llmProfileId);
await api.sendConversationMessage(c.id, msg);
onStarted(c);
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setSending(false);
}
}
const agentPicker = (
<Select value={agentKey} onValueChange={setAgentKey}>
<SelectTrigger size="sm" className="w-40 shrink-0">
<SelectValue placeholder="选择 Agent…" />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.key} value={a.key}>
<span className="flex items-center gap-2">
<Bot className="size-3.5" />
{a.name}
{!a.builtin && (
<Badge variant="outline" className="px-1 py-0 text-[9px]">自定义</Badge>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<>
{/* empty / landing state fills the panel */}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<div className="bg-primary/10 flex size-12 items-center justify-center rounded-full">
<Bot className="text-primary size-6" />
</div>
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
{agent?.description && (
<p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>
)}
</div>
<Composer
value={input}
onChange={setInput}
onSend={send}
disabled={sending || !agentKey}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
leftSlot={agentPicker}
/>
<LLMProfileRow
profiles={profiles}
selected={llmProfileId}
onChange={setLlmProfileId}
disabled={sending}
/>
</>
);
}
// ChatView is the right pane for one conversation — mirrors the task detail's
// main-agent console: header with agent + live badge + token/duration meta, a
// scroll-stick transcript (chat mode), and the composer.
function ChatView({
conv,
agents,
profiles,
onTitleMaybeChanged,
onConvUpdated,
}: {
conv: Conversation;
agents: Agent[];
profiles: LLMProfile[];
onTitleMaybeChanged: () => void;
onConvUpdated: () => void;
}) {
const [messages, setMessages] = React.useState<Activity[]>([]);
const [running, setRunning] = React.useState(false);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const cursorRef = React.useRef(0);
const agent = agents.find((a) => a.key === conv.agent_key);
const currentProfileId = conv.llm_profile_id ?? null;
async function changeProfile(id: number | null) {
try {
await api.updateConversationProfile(conv.id, id);
onConvUpdated();
} catch (e) {
toast.error("切换 LLM 失败:" + (e as Error).message);
}
}
// conversation-scoped detail fetcher for the reused Transcript renderer.
const fetchDetail = React.useCallback(
(seq: number) => api.conversationMsgDetail(conv.id, seq),
[conv.id],
);
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
const latestTodoSeq = React.useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const a = messages[i];
if (a.kind === "tool_use" && a.tool === "TodoWrite") return a.seq;
}
return null;
}, [messages]);
// reset + load whenever the selected conversation changes.
React.useEffect(() => {
cursorRef.current = 0;
setMessages([]);
setRunning(false);
let live = true;
api
.conversationMessages(conv.id, 0)
.then((r) => {
if (!live) return;
setMessages(r.items);
cursorRef.current = r.cursor;
setRunning(r.running);
})
.catch(() => {});
return () => {
live = false;
};
}, [conv.id]);
// poll while a turn is running: pull new steps after the cursor.
React.useEffect(() => {
if (!running) return;
let live = true;
const tick = async () => {
try {
const r = await api.conversationMessages(conv.id, cursorRef.current);
if (!live) return;
if (r.items.length) {
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
}
setRunning(r.running);
if (!r.running) onTitleMaybeChanged(); // first-turn auto-title landed
} catch {
/* transient — keep polling */
}
};
const h = setInterval(tick, 1000);
return () => {
live = false;
clearInterval(h);
};
}, [running, conv.id, onTitleMaybeChanged]);
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
const viewport = React.useCallback(
() =>
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
null,
[],
);
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
};
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport]);
// open/switch a conversation → jump to the latest (bottom)
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
vp.scrollTop = vp.scrollHeight;
atBottomRef.current = true;
}
}, [conv.id, viewport]);
// new activity → stick to bottom only if the user is already pinned there
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [messages, running, viewport]);
// Per-conversation token total, live — same accounting as the main-agent
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
const tokenTotal = React.useMemo(() => {
let i = 0, o = 0, cr = 0;
let li = 0, lo = 0, lcr = 0;
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
for (const a of messages) {
if (a.kind === "result") {
i += a.input_tokens ?? 0;
o += a.output_tokens ?? 0;
cr += a.cache_read_tokens ?? 0;
li = lo = lcr = 0;
} else if (a.kind === "usage") {
turns += 1;
li = a.input_tokens ?? 0;
lo = a.output_tokens ?? 0;
lcr = a.cache_read_tokens ?? 0;
}
}
const I = i + li, O = o + lo, CR = cr + lcr;
return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 };
}, [messages]);
async function send() {
const msg = input.trim();
if (!msg || sending || running) return;
setSending(true);
setInput("");
try {
await api.sendConversationMessage(conv.id, msg);
setRunning(true);
// pull the just-persisted human turn immediately.
const r = await api.conversationMessages(conv.id, cursorRef.current);
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setInput(msg); // restore so the user doesn't lose their text
} finally {
setSending(false);
}
}
// stop aborts the in-flight run for this conversation. running flips to false on
// the next 1s poll once the backend unwinds the agent; the trigger queue is not
// affected — the agent's next queued fire still starts.
async function stop() {
if (stopping) return;
setStopping(true);
try {
await api.stopConversation(conv.id);
} catch (e) {
toast.error("停止失败:" + (e as Error).message);
} finally {
setStopping(false);
}
}
return (
<>
{/* header: which agent + live + token meta */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<Bot className="text-muted-foreground size-4 shrink-0" />
<span className="shrink-0 text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
<span className="text-muted-foreground shrink-0 font-mono text-xs">{conv.agent_key}</span>
{agent && !agent.builtin && (
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
{agent?.description && (
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
)}
{running && <LiveBadge />}
<div className="text-muted-foreground ml-auto flex shrink-0 items-center gap-3 text-xs">
{tokenTotal.turns > 0 && (
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
{tokenTotal.turns} 轮
</span>
)}
{tokenTotal.any && (
<span title="input / cache(read) / output tokens" className="tabular-nums">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
)}
</div>
</div>
{/* messages */}
<ScrollArea
type="auto"
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
>
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
{messages.length === 0 && !running ? (
<div className="text-muted-foreground py-10 text-center text-sm">
开始和「{agent?.name ?? conv.agent_key}」对话
</div>
) : (
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
)}
</div>
</ScrollArea>
<Composer
value={input}
onChange={setInput}
onSend={send}
disabled={running}
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
running={running}
onStop={stop}
stopDisabled={stopping}
/>
<LLMProfileRow
profiles={profiles}
selected={currentProfileId}
onChange={changeProfile}
disabled={running || sending}
rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />}
/>
</>
);
}
// ConversationItem is one row in the left rail — status-ish icon, title + agent
// subtitle, inline rename, hover delete. Modeled on the console's SessionItem.
function ConversationItem({
conv,
agent,
active,
renaming,
renameText,
onSelect,
onStartRename,
onRenameText,
onCommitRename,
onDelete,
}: {
conv: Conversation;
agent?: Agent;
active: boolean;
renaming: boolean;
renameText: string;
onSelect: () => void;
onStartRename: () => void;
onRenameText: (v: string) => void;
onCommitRename: () => void;
onDelete: () => void;
}) {
return (
<div
className={cn(
"group flex items-center gap-1 rounded-md pr-1 transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{renaming ? (
<input
autoFocus
value={renameText}
onChange={(e) => onRenameText(e.target.value)}
onBlur={onCommitRename}
onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename();
if (e.key === "Escape") onCommitRename();
}}
className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
/>
) : (
<button
type="button"
onClick={onSelect}
onDoubleClick={onStartRename}
title="双击重命名"
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
>
<div className="truncate text-sm">{conv.title || "新对话"}</div>
<div className="text-muted-foreground flex items-center gap-1 truncate text-[11px]">
<Bot className="size-3 shrink-0" />
{agent?.name ?? conv.agent_key}
<span className="shrink-0">·</span>
<span className="shrink-0">{new Date(conv.created_at).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<span className="shrink-0 opacity-60">#{conv.id}</span>
</div>
</button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2Icon className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
<AlertDialogDescription>此操作不可撤销。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function ChatPage() {
const [agents, setAgents] = React.useState<Agent[]>([]);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [convs, setConvs] = React.useState<Conversation[]>([]);
const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState("");
const reloadConvs = React.useCallback(() => {
api.conversations().then(setConvs).catch(() => setConvs([]));
}, []);
React.useEffect(() => {
api.agents().then(setAgents).catch(() => {});
api.llmProfiles().then(setProfiles).catch(() => {});
reloadConvs();
}, [reloadConvs]);
const selected = convs.find((c) => c.id === selectedId) ?? null;
// conversation agents: custom agents + conversational built-ins (role=assistant,
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
// are task-specific and stay hidden from the chat page.
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
async function del(id: number) {
try {
await api.deleteConversation(id);
if (selectedId === id) setSelectedId(null);
reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
function startRename(c: Conversation) {
setRenamingId(c.id);
setRenameText(c.title || "");
}
async function commitRename() {
const id = renamingId;
const title = renameText.trim();
setRenamingId(null);
if (!id || !title) return;
try {
await api.renameConversation(id, title);
reloadConvs();
} catch (e) {
toast.error("重命名失败:" + (e as Error).message);
}
}
return (
<div
data-content-padding="false"
className="flex h-[calc(100svh-3rem)] flex-col overflow-hidden p-4 md:h-[calc(100svh-4rem)] md:p-6"
>
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] grid-rows-[minmax(0,1fr)] gap-4">
{/* left: conversation list */}
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div className="border-b p-2">
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话
</Button>
</div>
<ScrollArea type="auto" className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-2">
{convs.length === 0 && (
<p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>
)}
{convs.map((c) => (
<ConversationItem
key={c.id}
conv={c}
agent={agents.find((a) => a.key === c.agent_key)}
active={selectedId === c.id}
renaming={renamingId === c.id}
renameText={renameText}
onSelect={() => setSelectedId(c.id)}
onStartRename={() => startRename(c)}
onRenameText={setRenameText}
onCommitRename={commitRename}
onDelete={() => del(c.id)}
/>
))}
</div>
</ScrollArea>
</div>
{/* right: chat view */}
<div className="bg-card flex min-w-0 flex-col overflow-hidden rounded-lg border">
{selected ? (
<ChatView
key={selected.id}
conv={selected}
agents={agents}
profiles={profiles}
onTitleMaybeChanged={reloadConvs}
onConvUpdated={reloadConvs}
/>
) : (
<DraftChat
agents={chatAgents}
profiles={profiles}
onStarted={(c) => {
reloadConvs();
setSelectedId(c.id);
}}
/>
)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,306 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
ChevronRightIcon,
ShieldAlertIcon,
ArrowUpRightIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Finding, Severity } from "@/lib/types";
const SEVERITY_ORDER: Record<Severity, number> = { high: 3, medium: 2, low: 1 };
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function FindingsPage() {
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [expanded, setExpanded] = React.useState<string | null>(null);
const [findings, setFindings] = React.useState<Finding[]>([]);
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
React.useEffect(() => {
let alive = true;
const load = () => {
api
.findings()
.then((fs) => {
if (alive) setFindings(fs);
})
.catch(() => {});
};
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
const vulnclasses = React.useMemo(
() => Array.from(new Set(findings.map((f) => f.vulnclass))).sort(),
[findings],
);
const counts = React.useMemo(() => {
const c = { high: 0, medium: 0, low: 0 };
for (const f of findings) c[f.severity]++;
const tasks = new Set(findings.map((f) => f.task_id)).size;
return { ...c, total: findings.length, tasks };
}, [findings]);
const rows = React.useMemo(() => {
let list = findings.slice();
if (severity !== "all") list = list.filter((f) => f.severity === severity);
if (vulnclass !== "all")
list = list.filter((f) => f.vulnclass === vulnclass);
list.sort((a, b) => {
if (sort === "severity") {
const d = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
if (d !== 0) return d;
}
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
});
return list;
}, [findings, severity, vulnclass, sort]);
// reset to page 1 whenever filters change
React.useEffect(() => { setPage(1); }, [severity, vulnclass, sort]);
const paginated = React.useMemo(
() => rows.slice((page - 1) * pageSize, page * pageSize),
[rows, page, pageSize],
);
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: counts.total },
{ label: "高危", value: counts.high, tone: "text-red-500" },
{ label: "中危", value: counts.medium, tone: "text-amber-500" },
{ label: "低危", value: counts.low, tone: "text-slate-500" },
{ label: "涉及任务", value: counts.tasks },
];
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">发现</h1>
<p className="text-muted-foreground text-sm">跨任务漏洞汇总</p>
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{statCards.map((s) => (
<Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{s.label}</CardDescription>
<CardTitle
className={cn("text-2xl tabular-nums", s.tone)}
>
{s.value}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md border p-0.5">
{(
[
["all", "全部"],
["high", "高危"],
["medium", "中危"],
["low", "低危"],
] as const
).map(([val, label]) => (
<button
key={val}
onClick={() => setSeverity(val)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
severity === val
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{label}
</button>
))}
</div>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="漏洞类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
{vulnclasses.map((vc) => (
<SelectItem key={vc} value={vc}>
{vc}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={sort}
onValueChange={(v) => setSort(v as "severity" | "time")}
>
<SelectTrigger size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="severity">按严重度</SelectItem>
<SelectItem value="time">按时间</SelectItem>
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
共 {rows.length} 条
</span>
</div>
<Card className="py-0">
<CardContent className="px-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead className="w-28">漏洞类型</TableHead>
<TableHead>摘要</TableHead>
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
<TableHead className="w-32">时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((f) => {
const open = expanded === f.id;
return (
<React.Fragment key={f.id}>
<TableRow
className="cursor-pointer"
onClick={() => setExpanded(open ? null : f.id)}
>
<TableCell>
<ChevronRightIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
</TableCell>
<TableCell>
<StatusBadge domain="severity" value={f.severity} dot />
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{f.vulnclass}
</code>
</TableCell>
<TableCell className="max-w-md">
<span className="line-clamp-1">{f.summary}</span>
</TableCell>
<TableCell className="w-36 max-w-[9rem]">
{f.task_id ? (
<Link
href={`/function/tasks/detail?id=${f.task_id}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
title={f.task_description}
>
<span className="truncate">{f.task_description}</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(f.ts)}
</TableCell>
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-muted/30">
<div className="flex flex-col gap-2 px-2 py-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldAlertIcon className="size-3.5" />
证据 · {f.id}
{f.param_id && (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
{f.param_id}
</code>
)}
</div>
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
page={page}
pageSize={pageSize}
total={rows.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
</div>
</div>
);
}
+486
View File
@@ -0,0 +1,486 @@
"use client";
import * as React from "react";
import { AlertCircleIcon, CheckCircle2Icon, DownloadIcon, PlugZapIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { SSProject, SSTask } from "@/lib/types";
type SSStatus = {
exists: boolean;
configured: boolean;
enabled: boolean;
reachable: boolean;
url?: string;
tools: string[];
};
type Dimension = "project" | "task";
const ASSET_TYPES: { key: string; label: string }[] = [
{ key: "subdomain", label: "子域名" },
{ key: "service", label: "服务" },
{ key: "app", label: "App" },
];
export default function AssetSyncPage() {
return (
<div className="p-4 md:p-6">
<div className="mb-4">
<h1 className="font-semibold text-xl">资产同步</h1>
<p className="text-muted-foreground text-sm">从外部数据源同步资产入库</p>
</div>
<Tabs defaultValue="scopesentry">
<TabsList>
<TabsTrigger value="scopesentry">ScopeSentry</TabsTrigger>
</TabsList>
<TabsContent value="scopesentry" className="mt-4">
<ScopeSentryPanel />
</TabsContent>
</Tabs>
</div>
);
}
function ScopeSentryPanel() {
const [status, setStatus] = React.useState<SSStatus | null>(null);
const [loadingStatus, setLoadingStatus] = React.useState(true);
const loadStatus = React.useCallback(() => {
setLoadingStatus(true);
api
.ssStatus()
.then(setStatus)
.catch((e) => toast.error(`读取数据源状态失败:${e.message}`))
.finally(() => setLoadingStatus(false));
}, []);
React.useEffect(() => {
loadStatus();
}, [loadStatus]);
const ready = !!status && status.exists && status.configured && status.enabled;
return (
<div className="space-y-4">
<DataSourceCard status={status} loading={loadingStatus} onChanged={loadStatus} />
{ready ? (
<SyncWorkbench />
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
数据源就绪后即可选择项目 / 任务进行同步。
</CardContent>
</Card>
)}
</div>
);
}
// ── 数据源状态卡 ─────────────────────────────────────────────────────────────
function DataSourceCard({
status,
loading,
onChanged,
}: {
status: SSStatus | null;
loading: boolean;
onChanged: () => void;
}) {
const [url, setUrl] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (status?.url) setUrl(status.url);
}, [status?.url]);
const create = async () => {
setBusy(true);
try {
await api.ssDatasource({});
toast.success("已创建 ScopeSentry 数据源,请填写地址与密钥");
onChanged();
} catch (e) {
toast.error(`创建失败:${(e as Error).message}`);
} finally {
setBusy(false);
}
};
const save = async () => {
if (!url.trim()) return toast.error("请填写 MCP 地址");
setBusy(true);
try {
const r = await api.ssDatasource({ url: url.trim(), api_key: apiKey.trim() });
toast.success(r.enabled ? "已保存并启用数据源" : "已保存(尚未满足启用条件)");
setApiKey("");
onChanged();
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
} finally {
setBusy(false);
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<PlugZapIcon className="size-4" /> 数据源状态
<StatusBadge status={status} loading={loading} />
</CardTitle>
<Button variant="ghost" size="sm" onClick={onChanged} disabled={loading}>
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} /> 刷新
</Button>
</CardHeader>
<CardContent className="space-y-3">
{!status?.exists ? (
<div className="flex items-center justify-between gap-4">
<p className="text-muted-foreground text-sm">
尚未创建 ScopeSentry 数据源。创建后会新增一个占位 MCP(地址/密钥为空、未启用)。
</p>
<Button onClick={create} disabled={busy}>
创建数据源
</Button>
</div>
) : (
<>
{!status.configured && (
<p className="text-amber-600 text-sm dark:text-amber-500">
数据源已创建但未配置,请填写 MCP 地址与 API Key 后启用。
</p>
)}
{status.configured && !status.enabled && (
<p className="text-amber-600 text-sm dark:text-amber-500">数据源已配置但未启用,保存后将自动启用。</p>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label>MCP 地址</Label>
<Input placeholder="http://<主机>:8082/mcp" value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>API Key(X-API-Key,留空保留原值)</Label>
<Input
type="password"
placeholder="ssk_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button onClick={save} disabled={busy}>
保存并启用
</Button>
{status.enabled && status.tools.length > 0 && (
<span className="text-muted-foreground text-xs">已发现 {status.tools.length} 个工具</span>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
function StatusBadge({ status, loading }: { status: SSStatus | null; loading: boolean }) {
if (loading || !status) return <Badge variant="secondary">检测中…</Badge>;
if (!status.exists) return <Badge variant="destructive">未创建</Badge>;
if (!status.configured) return <Badge variant="outline">未配置</Badge>;
if (!status.enabled) return <Badge variant="outline">未启用</Badge>;
if (status.reachable)
return (
<Badge className="bg-emerald-600 hover:bg-emerald-600">
<CheckCircle2Icon className="mr-1 size-3" /> 已连接
</Badge>
);
return (
<Badge variant="destructive">
<AlertCircleIcon className="mr-1 size-3" /> 不可达
</Badge>
);
}
// ── 同步工作区(项目 / 任务维度)────────────────────────────────────────────────
function SyncWorkbench() {
const [dimension, setDimension] = React.useState<Dimension>("project");
const [assetTypes, setAssetTypes] = React.useState<Record<string, boolean>>({
subdomain: true,
service: true,
app: true,
});
const [createCompany, setCreateCompany] = React.useState(true);
const [projects, setProjects] = React.useState<SSProject[]>([]);
const [tasks, setTasks] = React.useState<SSTask[]>([]);
const [loading, setLoading] = React.useState(false);
const [search, setSearch] = React.useState("");
const [page, setPage] = React.useState(1);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [syncing, setSyncing] = React.useState(false);
const [result, setResult] = React.useState<Awaited<ReturnType<typeof api.ssSync>> | null>(null);
const load = React.useCallback(() => {
setLoading(true);
setSelected(new Set());
const fn =
dimension === "project"
? api.ssProjects(page, 50, search).then((r) => setProjects(r.projects))
: api.ssTasks(page, 50, search).then(setTasks);
fn.catch((e) => toast.error(`加载列表失败:${e.message}`)).finally(() => setLoading(false));
}, [dimension, page, search]);
React.useEffect(() => {
load();
}, [load]);
const rows = dimension === "project" ? projects : tasks;
const idOf = (row: SSProject | SSTask) => (dimension === "project" ? (row as SSProject).id : (row as SSTask).name);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
setSelected((prev) => (prev.size === rows.length ? new Set() : new Set(rows.map(idOf))));
};
const chosenTypes = ASSET_TYPES.filter((t) => assetTypes[t.key]).map((t) => t.key);
const runSync = async () => {
if (selected.size === 0) return toast.error(`请至少选择一个${dimension === "project" ? "项目" : "任务"}`);
if (chosenTypes.length === 0) return toast.error("请至少选择一种资产类型");
setSyncing(true);
setResult(null);
try {
const r = await api.ssSync({
dimension,
targets: [...selected],
asset_types: chosenTypes,
create_company: dimension === "project" ? createCompany : false,
});
setResult(r);
const total = Object.values(r.synced ?? {}).reduce((a, b) => a + b, 0);
toast.success(`同步完成,共入库 ${total} 条资产`);
} catch (e) {
toast.error(`同步失败:${(e as Error).message}`);
} finally {
setSyncing(false);
}
};
const renderRows = () => {
if (loading) {
return (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
加载中…
</TableCell>
</TableRow>
);
}
if (rows.length === 0) {
return (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
无数据
</TableCell>
</TableRow>
);
}
if (dimension === "project") {
return projects.map((p) => (
<TableRow key={p.id} className="cursor-pointer" onClick={() => toggle(p.id)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(p.id)} onCheckedChange={() => toggle(p.id)} />
</TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>{p.tag ? <Badge variant="secondary">{p.tag}</Badge> : "—"}</TableCell>
<TableCell className="text-right">{p.AssetCount ?? 0}</TableCell>
</TableRow>
));
}
return tasks.map((t) => (
<TableRow key={t.id} className="cursor-pointer" onClick={() => toggle(t.name)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(t.name)} onCheckedChange={() => toggle(t.name)} />
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<Badge variant={t.progress === 100 ? "secondary" : "outline"}>
{t.progress != null ? `${t.progress}%` : "—"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">{t.endTime || t.creatTime || "—"}</TableCell>
</TableRow>
));
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">选择数据同步</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 维度切换 */}
<Tabs
value={dimension}
onValueChange={(v) => {
setDimension(v as Dimension);
setPage(1);
}}
>
<TabsList>
<TabsTrigger value="project">项目维度</TabsTrigger>
<TabsTrigger value="task">任务维度</TabsTrigger>
</TabsList>
</Tabs>
{/* 资产类型 + 选项 */}
<div className="flex flex-wrap items-center gap-4">
<span className="font-medium text-sm">同步资产:</span>
{ASSET_TYPES.map((t) => (
<label key={t.key} htmlFor={`at-${t.key}`} className="flex items-center gap-1.5 text-sm">
<Checkbox
id={`at-${t.key}`}
checked={!!assetTypes[t.key]}
onCheckedChange={(c) => setAssetTypes((prev) => ({ ...prev, [t.key]: !!c }))}
/>
{t.label}
</label>
))}
{dimension === "project" && (
<label htmlFor="create-company" className="flex items-center gap-1.5 text-sm">
<Checkbox id="create-company" checked={createCompany} onCheckedChange={(c) => setCreateCompany(!!c)} />
按项目建立企业并写入资产范围
</label>
)}
</div>
{/* 搜索 + 操作 */}
<div className="flex items-center gap-2">
<div className="relative max-w-xs flex-1">
<SearchIcon className="absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder={dimension === "project" ? "搜索项目名" : "搜索任务名"}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setPage(1);
load();
}
}}
/>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} />
</Button>
<div className="flex-1" />
<span className="text-muted-foreground text-xs">已选 {selected.size}</span>
<Button onClick={runSync} disabled={syncing || selected.size === 0}>
<DownloadIcon className={syncing ? "size-4 animate-pulse" : "size-4"} /> 同步选中
</Button>
</div>
{/* 列表 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox checked={rows.length > 0 && selected.size === rows.length} onCheckedChange={toggleAll} />
</TableHead>
<TableHead>{dimension === "project" ? "项目名" : "任务名"}</TableHead>
{dimension === "project" ? (
<>
<TableHead>标签</TableHead>
<TableHead className="text-right">资产数</TableHead>
</>
) : (
<>
<TableHead>状态</TableHead>
<TableHead>时间</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>{renderRows()}</TableBody>
</Table>
</div>
{/* 分页 */}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
上一页
</Button>
<span className="text-muted-foreground text-xs">第 {page} 页</span>
<Button
variant="outline"
size="sm"
disabled={rows.length < 50 || loading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
{/* 结果 */}
{result && <SyncResult result={result} />}
</CardContent>
</Card>
);
}
function SyncResult({ result }: { result: Awaited<ReturnType<typeof api.ssSync>> }) {
const synced = result.synced ?? {};
const labels: Record<string, string> = { subdomain: "子域名", service: "服务", app: "App", ip: "IP" };
return (
<div className="space-y-2 rounded-md border bg-muted/40 p-3 text-sm">
<div className="flex flex-wrap gap-3">
{Object.entries(synced).map(([k, v]) => (
<Badge key={k} variant="secondary">
{labels[k] ?? k}: {v}
</Badge>
))}
</div>
{result.companies && result.companies.length > 0 && (
<p className="text-muted-foreground">新建/更新企业:{result.companies.join("、")}</p>
)}
{result.warnings && result.warnings.length > 0 && (
<ul className="list-inside list-disc text-amber-600 dark:text-amber-500">
{result.warnings.map((wm) => (
<li key={wm}>{wm}</li>
))}
</ul>
)}
{result.errors && result.errors.length > 0 && (
<ul className="list-inside list-disc text-destructive">
{result.errors.slice(0, 20).map((em) => (
<li key={em}>{em}</li>
))}
{result.errors.length > 20 && <li>…共 {result.errors.length} 条错误</li>}
</ul>
)}
</div>
);
}
@@ -0,0 +1,355 @@
"use client";
import * as React from "react";
import {
GlobeIcon,
NetworkIcon,
LayoutTemplateIcon,
LinkIcon,
KeyRoundIcon,
ChevronLeftIcon,
ChevronRightIcon,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/lib/api";
import type { Asset } from "@/lib/types";
const PAGE_SIZES = [25, 50, 100, 200];
const METHOD_COLOR: Record<string, string> = {
GET: "bg-emerald-100 text-emerald-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-orange-100 text-orange-700",
DELETE: "bg-red-100 text-red-700",
HEAD: "bg-purple-100 text-purple-700",
OPTIONS: "bg-slate-100 text-slate-600",
};
function MethodBadge({ method }: { method: string }) {
const m = method.toUpperCase();
return (
<span className={cn("inline-block rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none", METHOD_COLOR[m] ?? "bg-muted text-muted-foreground")}>
{m || "—"}
</span>
);
}
function statusTone(code: number) {
if (code >= 500) return "text-red-500";
if (code >= 400) return "text-amber-500";
if (code >= 300) return "text-blue-500";
if (code >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
function fmtBytes(n?: number | null) {
if (!n || !Number.isFinite(n) || n <= 0) return "—";
const u = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return `${i === 0 ? n : (n / 1024 ** i).toFixed(1)} ${u[i]}`;
}
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
const clean = items.filter(Boolean);
if (clean.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
return (
<div className="flex flex-wrap gap-1">
{clean.map((s, i) => (
<Badge key={i} variant="outline" className={cn("text-[10px]", mono && "font-mono")}>{s}</Badge>
))}
</div>
);
}
function AssetCard({
cols, total, page, size, onSize, onPage, children,
}: {
cols: string[];
total: number;
page: number;
size: number;
onSize: (v: number) => void;
onPage: (fn: (p: number) => number) => void;
children: React.ReactNode;
}) {
const rows = React.Children.toArray(children);
const pageCount = Math.max(1, Math.ceil(total / size));
const start = total === 0 ? 0 : page * size + 1;
const end = page * size + rows.length;
return (
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{cols.map((c) => <TableHead key={c}>{c}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{rows.length > 0 ? rows : (
<TableRow>
<TableCell colSpan={cols.length} className="py-12 text-center text-sm text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{total > 0 && (
<div className="flex shrink-0 items-center gap-2 border-t px-3 py-1.5 text-xs text-muted-foreground">
<Select value={String(size)} onValueChange={(v) => onSize(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-24"><SelectValue /></SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => <SelectItem key={n} value={String(n)}>{n} / 页</SelectItem>)}
</SelectContent>
</Select>
<span className="tabular-nums">{start}–{end} / {total}</span>
{pageCount > 1 && (
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={page <= 0} onClick={() => onPage((p) => Math.max(0, p - 1))}>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">{page + 1} / {pageCount}</span>
<Button variant="outline" size="icon" className="size-7" disabled={page + 1 >= pageCount} onClick={() => onPage((p) => Math.min(pageCount - 1, p + 1))}>
<ChevronRightIcon />
</Button>
</div>
)}
</div>
)}
</Card>
);
}
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
{ key: "ip", label: "IP", icon: NetworkIcon },
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
{ key: "endpoint", label: "接口", icon: LinkIcon },
];
export function AssetsTab({ taskId }: { taskId: string }) {
const [assets, setAssets] = React.useState<Asset[]>([]);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("root_domain");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
React.useEffect(() => setPage(0), [tab, size]);
React.useEffect(() => {
let alive = true;
const load = async () => {
try {
const results = await Promise.all(
TABS.map((t) => api.taskAssets(taskId, t.key)),
);
if (!alive) return;
setAssets(results.flat());
setLoaded(true);
} catch {
if (alive) setLoaded(true);
}
};
load();
const timer = setInterval(load, 10_000);
return () => { alive = false; clearInterval(timer); };
}, [taskId]);
const byType = React.useMemo(() => {
const m: Record<string, Asset[]> = { root_domain: [], ip: [], subdomain: [], service: [], endpoint: [] };
for (const a of assets) {
if (a.type in m) m[a.type].push(a);
}
return m;
}, [assets]);
const counts: Record<string, number> = {
root_domain: byType.root_domain.length,
ip: byType.ip.length,
subdomain: byType.subdomain.length,
service: byType.service.length,
endpoint: byType.endpoint.length,
};
const totalAll = assets.length;
if (!loaded) {
return <div className="py-20 text-center text-sm text-muted-foreground">加载中…</div>;
}
if (totalAll === 0) {
return (
<div className="py-20 text-center text-sm text-muted-foreground">
该任务暂无涉及资产
<p className="mt-1 text-xs">资产在任务执行过程中由 agent 发现并记录</p>
</div>
);
}
const cur = byType[tab] ?? [];
const slice = cur.slice(page * size, page * size + size);
return (
<Tabs orientation="vertical" value={tab} onValueChange={setTab} className="items-start gap-4">
{/* left nav */}
<div className="w-36 shrink-0 rounded-lg border bg-card p-1 shadow-sm">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
tab === t.key
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<t.icon className="size-3.5 shrink-0" />
<span className="flex-1 text-left">{t.label}</span>
<span className="tabular-nums text-xs opacity-70">{counts[t.key] ?? 0}</span>
</button>
))}
</div>
{/* right content */}
<div className="min-w-0 flex-1 flex flex-col gap-4">
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "ICP 备案"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="text-xs">{a.icp || "—"}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.ip}</TableCell>
<TableCell className="font-mono text-xs">{a.c_segment || "—"}</TableCell>
<TableCell><Chips items={a.bound_domains ?? []} mono /></TableCell>
<TableCell>
<Chips
items={(a.open_ports ?? []).map((p) =>
p.service ? `${p.port}/${p.service}` : String(p.port),
)}
mono
/>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "根域名", "解析类型", "解析值"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="font-mono text-xs">{a.root_domain || "—"}</TableCell>
<TableCell className="text-xs">{a.record_type || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs">{(Array.isArray(a.record_value) ? a.record_value.join(", ") : a.record_value) || "—"}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证"]}
total={cur.length}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
>
{slice.map((a) => {
const isHttp = a.service_type === "http";
const addr = isHttp
? (a.url || "")
: (a.service_name || [a.ip || a.domain, a.port].filter(Boolean).join(":"));
return (
<TableRow key={a.id}>
<TableCell className="max-w-xs truncate font-mono text-xs" title={addr}>{addr || "—"}</TableCell>
<TableCell>
{a.status_code != null ? (
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(a.status_code))}>
{a.status_code}
</span>
) : "—"}
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs">{a.page_title || "—"}</TableCell>
<TableCell className="text-xs tabular-nums">{fmtBytes(a.content_length)}</TableCell>
<TableCell><Chips items={a.technologies ?? []} /></TableCell>
<TableCell>
{(a.auth ?? []).length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
(a.auth ?? []).map((authItem, i) => {
const item = authItem as Record<string, string>;
return (
<span key={i} className="inline-flex items-center gap-1 text-[11px]">
<KeyRoundIcon className="size-3 text-muted-foreground" />
<span className="font-mono">{item.type || item.username || "认证"}</span>
</span>
);
})
)}
</TableCell>
</TableRow>
);
})}
</AssetCard>
</TabsContent>
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["方法", "完整地址", "参数"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="w-16"><MethodBadge method={a.method || ""} /></TableCell>
<TableCell className="max-w-sm truncate font-mono text-xs" title={a.url}>{a.url || "—"}</TableCell>
<TableCell>
<Chips
items={(a.params ?? []).map((p) => {
const item = p as Record<string, string>;
return item.name ? `${item.name}(${item.location || "?"})` : "?";
})}
mono
/>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
</div>
</Tabs>
);
}
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
import { ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Finding } from "@/lib/types";
function Row({ f }: { f: Finding }) {
const [open, setOpen] = React.useState(false);
return (
<div className="border-b last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-accent/40"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<span className="w-20 shrink-0 font-medium">{f.vulnclass}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{f.summary}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(f.ts).toLocaleString("zh-CN")}
</span>
</button>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 text-xs font-medium text-muted-foreground">
证据 / PoC
</div>
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
</div>
)}
</div>
);
}
export function FindingsTab({ taskId }: { taskId: string }) {
const [findings, setFindings] = React.useState<Finding[]>([]);
React.useEffect(() => {
let active = true;
const load = () => {
api
.findings(taskId)
.then((fs) => {
if (active) setFindings(fs);
})
.catch(() => {});
};
load();
const t = setInterval(load, 3000);
return () => {
active = false;
clearInterval(t);
};
}, [taskId]);
const items = findings
.filter((f) => f.task_id === taskId)
.sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 };
return order[a.severity] - order[b.severity];
});
return (
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
{items.map((f) => (
<Row key={f.id} f={f} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
本任务暂无确认发现。
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,633 @@
"use client";
"use no memo";
import * as React from "react";
import {
Background,
BackgroundVariant,
Controls,
type Edge as RFEdge,
Handle,
MarkerType,
MiniMap,
type Node as RFNode,
type NodeProps,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Bug,
Compass,
Flag,
FlaskConical,
Lightbulb,
type LucideIcon,
Target,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/status-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { toneClasses, type Tone } from "@/lib/status";
import { api } from "@/lib/api";
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
const relLabel: Record<string, string> = {
spawns: "派生",
derived_from: "意图链",
yields: "产出",
proves: "证明",
};
const relColor: Record<string, string> = {
spawns: "#10b981",
derived_from: "#6366f1",
yields: "#f59e0b",
proves: "#ef4444",
};
type TypeMeta = {
label: string;
icon: LucideIcon;
iconBg: string; // Dify 风格实心图标块底色
chip: string; // 抽屉里用的柔和底色
hex: string; // minimap / 连线取色
};
const typeMeta: Record<ExploreKind, TypeMeta> = {
task: {
label: "根任务",
icon: Flag,
iconBg: "bg-slate-500",
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
hex: "#64748b",
},
begin: {
label: "起点",
icon: Flag,
iconBg: "bg-slate-500",
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
hex: "#64748b",
},
goal: {
label: "目标",
icon: Target,
iconBg: "bg-emerald-500",
chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
hex: "#10b981",
},
hint: {
label: "提示",
icon: Lightbulb,
iconBg: "bg-violet-500",
chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
hex: "#8b5cf6",
},
intent: {
label: "意图",
icon: Compass,
iconBg: "bg-blue-500",
chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
hex: "#3b82f6",
},
fact: {
label: "事实",
icon: FlaskConical,
iconBg: "bg-amber-500",
chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
hex: "#f59e0b",
},
finding: {
label: "漏洞",
icon: Bug,
iconBg: "bg-rose-500",
chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
hex: "#f43f5e",
},
};
// The task root is now an origin fact (kind='fact', state='origin') rather than a
// dedicated 'begin' node. Render/layout it with the "起点" visual (the `begin`
// entry above) so the graph root still reads as the origin.
function viewKind(n: TaskNode): ExploreKind {
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
}
function prioTone(p: number): Tone {
return p >= 8 ? "red" : p >= 5 ? "amber" : "slate";
}
// 各类型节点在卡片上展示的字段:payload 通常是一段 JSON 字符串,
// 按类型取其中的 summary / text / description 作为显示内容。
const summaryFields: Record<ExploreKind, string[]> = {
task: ["summary", "description"],
begin: ["summary", "description"],
goal: ["text"],
hint: [],
intent: ["summary"],
fact: ["summary"],
finding: ["summary"],
};
// 解析 payload 得到节点显示文案;解析失败或字段缺失时回退到原始 payload。
function nodeSummary(n: TaskNode): string {
const raw = n.payload ?? "";
const fields = summaryFields[viewKind(n)] ?? [];
if (fields.length && raw.trim()) {
try {
const obj: unknown = JSON.parse(raw);
if (obj && typeof obj === "object") {
for (const f of fields) {
const v = (obj as Record<string, unknown>)[f];
if (typeof v === "string" && v.trim()) return v;
}
}
} catch {
/* 非 JSON:按原始内容显示 */
}
}
return raw;
}
const COL_W = 360;
const ROW_H = 140;
type ExploreNodeData = { node: TaskNode };
type ExploreRFNode = RFNode<ExploreNodeData, "explore">;
function pushMap(m: Map<string, string[]>, k: string, v: string) {
const a = m.get(k);
if (a) a.push(v);
else m.set(k, [v]);
}
// 按连线的“递进深度”自动分层:顺着边从左往右排(意图→事实,事实/漏洞→再生意图…),
// 而不是按节点类型固定分列——这样反向/回环的边才不会互相缠绕。
// 1) DFS 断环:指向递归栈中节点的边视为回边,不参与分层(处理 意图↔事实 这类回环);
// 2) 最长路径分层:谁在链路上更靠后,列就越靠右;
// 3) 重心排序:每层按相邻层邻居的平均位置重排,让有连线的节点对齐、减少交叉;
// 4) 每层整体垂直居中。
function computeLayout(
nodes: TaskNode[],
edges: Edge[],
): Map<string, { x: number; y: number }> {
const order = nodes.map((n) => n.id);
const idset = new Set(order);
const valid = edges.filter((e) => idset.has(e.src) && idset.has(e.dst));
const key = (s: string, d: string) => `${s}->${d}`;
const out = new Map<string, string[]>();
for (const e of valid) pushMap(out, e.src, e.dst);
// 1) 迭代式 DFS 断环
const seen = new Map<string, 0 | 1 | 2>();
const back = new Set<string>();
for (const root of order) {
if ((seen.get(root) ?? 0) !== 0) continue;
const stack: Array<{ u: string; i: number }> = [{ u: root, i: 0 }];
seen.set(root, 1);
while (stack.length) {
const top = stack[stack.length - 1];
const nbrs = out.get(top.u) ?? [];
if (top.i >= nbrs.length) {
seen.set(top.u, 2);
stack.pop();
continue;
}
const v = nbrs[top.i++];
const s = seen.get(v) ?? 0;
if (s === 1) back.add(key(top.u, v)); // 回边
else if (s === 0) {
seen.set(v, 1);
stack.push({ u: v, i: 0 });
}
}
}
// 2) 最长路径分层(忽略回边)
const dagSucc = new Map<string, string[]>();
const indeg = new Map<string, number>(order.map((id) => [id, 0]));
for (const e of valid) {
if (back.has(key(e.src, e.dst))) continue;
pushMap(dagSucc, e.src, e.dst);
indeg.set(e.dst, (indeg.get(e.dst) ?? 0) + 1);
}
const layer = new Map<string, number>(order.map((id) => [id, 0]));
const queue = order.filter((id) => (indeg.get(id) ?? 0) === 0);
for (let qi = 0; qi < queue.length; qi++) {
const u = queue[qi];
for (const v of dagSucc.get(u) ?? []) {
layer.set(v, Math.max(layer.get(v) ?? 0, (layer.get(u) ?? 0) + 1));
const d = (indeg.get(v) ?? 0) - 1;
indeg.set(v, d);
if (d === 0) queue.push(v);
}
}
// 3) 按层分列
const maxLayer = Math.max(0, ...layer.values());
const columns: string[][] = Array.from({ length: maxLayer + 1 }, () => []);
for (const id of order) columns[layer.get(id) ?? 0].push(id);
// 重心排序:只统计相邻层之间的连线
const colIndexOf = new Map<string, number>();
columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c)));
const neighbors = new Map<string, string[]>();
for (const e of valid) {
const cs = colIndexOf.get(e.src);
const cd = colIndexOf.get(e.dst);
if (cs === undefined || cd === undefined || Math.abs(cs - cd) !== 1) continue;
pushMap(neighbors, e.src, e.dst);
pushMap(neighbors, e.dst, e.src);
}
const sweep = (dir: 1 | -1) => {
const start = dir === 1 ? 1 : columns.length - 2;
const end = dir === 1 ? columns.length : -1;
for (let c = start; c !== end; c += dir) {
const ref = c - dir;
const refIndex = new Map(columns[ref].map((id, i) => [id, i]));
const scored = columns[c].map((id, i) => {
const ns = (neighbors.get(id) ?? [])
.map((nid) => refIndex.get(nid))
.filter((v): v is number => v !== undefined);
const bary = ns.length ? ns.reduce((a, b) => a + b, 0) / ns.length : i;
return { id, bary, i };
});
scored.sort((a, b) => a.bary - b.bary || a.i - b.i);
columns[c] = scored.map((s) => s.id);
}
};
for (let iter = 0; iter < 4; iter++) {
sweep(1);
sweep(-1);
}
// 4) 垂直居中
const maxCount = Math.max(1, ...columns.map((c) => c.length));
const midline = ((maxCount - 1) * ROW_H) / 2;
const pos = new Map<string, { x: number; y: number }>();
columns.forEach((ids, c) => {
const startY = midline - ((ids.length - 1) * ROW_H) / 2;
ids.forEach((id, i) => pos.set(id, { x: c * COL_W, y: startY + i * ROW_H }));
});
return pos;
}
// Dify 工作流风格节点:白色圆角卡片 + 实心配色图标块 + 选中/运行蓝色描边。
function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
const n = data.node;
const kind = viewKind(n);
const meta = typeMeta[kind] ?? typeMeta.intent;
const Icon = meta.icon;
const showState = n.type === "goal" || n.type === "intent";
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
const isLive = n.type === "intent" && n.state === "running";
return (
<div
className={cn(
"w-[240px] cursor-pointer rounded-2xl border-2 transition-colors",
selected ? "border-blue-500" : isLive ? "border-blue-400/70" : "border-transparent",
)}
>
<div className="group relative rounded-[14px] border border-black/[0.06] bg-white shadow-sm transition-shadow hover:shadow-lg dark:border-white/10 dark:bg-neutral-900">
<Handle
type="target"
position={Position.Left}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
{/* 头部:实心图标块 + 类型标题 + 状态点 / 优先级 */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5">
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm",
meta.iconBg,
)}
>
<Icon className="size-3.5 text-white" />
</span>
<span className="grow truncate text-[13px] font-semibold text-neutral-700 dark:text-neutral-100">
{meta.label}
</span>
{isLive && (
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex size-2 rounded-full bg-blue-500" />
</span>
)}
{showPriority && (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
toneClasses[prioTone(n.priority)],
)}
>
P{n.priority}
</span>
)}
</div>
{/* 摘要 */}
<div className="line-clamp-3 px-3 pb-2 text-[13px] leading-snug text-neutral-500 dark:text-neutral-400">
{nodeSummary(n) || meta.label}
</div>
{/* 底部:状态徽章 + 来源/ID */}
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? (
<StatusBadge
domain={n.type === "goal" ? "goal" : "intent"}
value={n.state}
dot
className="px-1.5 py-0 text-[10px]"
/>
) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">
{meta.label}
</span>
)}
<span className="shrink-0 font-mono text-[10px] text-neutral-400 dark:text-neutral-500">
#{n.id}
</span>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
</div>
</div>
);
}
const nodeTypes = { explore: ExploreNode };
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5 text-sm">
<span className="text-muted-foreground w-14 shrink-0">{label}</span>
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
</div>
);
}
// 点击节点后弹出的抽屉:上半展示可读摘要,下半展示节点原始 JSON。
function NodeDetailSheet({
node,
onOpenChange,
}: {
node: TaskNode | null;
onOpenChange: (open: boolean) => void;
}) {
const [copied, setCopied] = React.useState(false);
const meta = node ? (typeMeta[viewKind(node)] ?? typeMeta.intent) : null;
const Icon = meta?.icon;
const raw = node ? JSON.stringify(node, null, 2) : "";
const copy = () => {
void navigator.clipboard?.writeText(raw).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
};
return (
<Sheet open={node !== null} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md"
>
{node && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<div className="flex items-center gap-2.5 pr-8">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm",
meta.iconBg,
)}
>
<Icon className="size-4 text-white" />
</span>
<div className="min-w-0">
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
<section>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">属性</h4>
<DetailRow label="类型">{meta.label}</DetailRow>
<DetailRow label="状态">
{node.type === "goal" || node.type === "intent" ? (
<StatusBadge
domain={node.type === "goal" ? "goal" : "intent"}
value={node.state}
dot
className="px-1.5 py-0 text-[10px]"
/>
) : (
<span className="font-mono text-xs">{node.state}</span>
)}
</DetailRow>
<DetailRow label="优先级">
<span className="tabular-nums">{node.priority}</span>
</DetailRow>
<DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span>
</DetailRow>
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section>
<section className="border-t pt-3">
<div className="mb-1.5 flex items-center justify-between">
<h4 className="text-muted-foreground text-xs font-medium">原始数据</h4>
<button
type="button"
onClick={copy}
className="text-muted-foreground hover:text-foreground text-xs transition-colors"
>
{copied ? "已复制" : "复制"}
</button>
</div>
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
{raw}
</pre>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
);
}
function GraphInner({ taskId }: { taskId: string }) {
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<ExploreRFNode>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
const [isEmpty, setIsEmpty] = React.useState(true);
const [selected, setSelected] = React.useState<TaskNode | null>(null);
React.useEffect(() => {
let cancelled = false;
const load = () => {
api
.explorationGraph(taskId)
.then((g) => {
if (cancelled) return;
const nodes = g.nodes ?? [];
const edges: Edge[] = g.edges ?? [];
setIsEmpty(nodes.length === 0);
// 抽屉打开时,让其中的数据随轮询保持最新。
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
// 目标指向「执行中」意图的连线做流动动画,直观展示活跃探索路径。
const liveTargets = new Set(
nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
);
const layout = computeLayout(nodes, edges);
setRfNodes((prev) => {
const prevPos = new Map(prev.map((p) => [p.id, p.position]));
return nodes.map((n) => ({
id: n.id,
type: "explore" as const,
position: prevPos.get(n.id) ?? layout.get(n.id) ?? { x: 0, y: 0 },
data: { node: n },
sourcePosition: Position.Right,
targetPosition: Position.Left,
}));
});
setRfEdges(
edges.map((e, i) => {
const color = relColor[e.rel] ?? "#94a3b8";
const animated = liveTargets.has(e.dst);
return {
id: `e${i}-${e.src}-${e.dst}`,
source: e.src,
target: e.dst,
label: relLabel[e.rel] ?? e.rel,
type: "default",
animated,
labelShowBg: false,
labelStyle: { fontSize: 10, fontWeight: 600, fill: color },
style: {
stroke: color,
strokeWidth: 2,
strokeOpacity: animated ? 0.9 : 0.55,
},
markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 },
} satisfies RFEdge;
}),
);
})
.catch(() => {
/* keep last good data */
});
};
load();
const timer = setInterval(load, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId, setRfNodes, setRfEdges]);
return (
<>
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => setSelected(node.data.node)}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
minZoom={0.1}
className="!bg-[#f0f2f7] dark:!bg-neutral-950"
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
className="text-neutral-400/50 dark:text-neutral-700/60"
/>
<Controls
showInteractive={false}
className="!rounded-lg !border !shadow-sm [&>button]:!border-border [&>button]:!bg-card [&>button:hover]:!bg-accent [&_svg]:!fill-foreground"
/>
<MiniMap
pannable
zoomable
className="!bg-card !rounded-lg !border !shadow-sm"
maskColor="rgb(148 163 184 / 0.18)"
nodeColor={(node) => {
const data = node.data as ExploreNodeData | undefined;
const k = data?.node ? viewKind(data.node) : "intent";
return typeMeta[k]?.hex ?? "#94a3b8";
}}
nodeStrokeWidth={0}
nodeBorderRadius={4}
/>
<Panel position="top-left">
<div className="bg-card/95 flex flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
{isEmpty && <span className="text-muted-foreground">暂无探索数据</span>}
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{(["begin", "goal", "intent", "fact", "finding", "hint"] as ExploreKind[]).map((k) => {
const m = typeMeta[k];
const Icon = m.icon;
return (
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
<Icon className="size-2.5 text-white" />
</span>
{m.label}
</span>
);
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
{Object.entries(relLabel).map(([k, v]) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: relColor[k] }} />
{v}
</span>
))}
</div>
</div>
</Panel>
</ReactFlow>
<NodeDetailSheet node={selected} onOpenChange={(o) => !o && setSelected(null)} />
</>
);
}
export function GraphTab({ taskId }: { taskId: string }) {
return (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<ReactFlowProvider>
<GraphInner taskId={taskId} />
</ReactFlowProvider>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,217 @@
"use client";
import * as React from "react";
import { CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
function fmtTime(s: string) {
if (!s) return "—";
return new Date(s).toLocaleString("zh-CN", {
month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function AgentChip({ name }: { name: string }) {
if (!name) return <span className="text-muted-foreground text-xs">—</span>;
const color =
name === "planner" ? "bg-amber-500" :
name === "mainagent" ? "bg-primary" :
"bg-sky-600";
return (
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium text-white ${color}`}>
{name}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
const short = s.length > 100 ? s.slice(0, 100) + "…" : s;
return (
<Tooltip>
<TooltipTrigger asChild>
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">{short}</code>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
</TooltipContent>
</Tooltip>
);
}
interface Props {
taskId: string;
}
export function InterceptTab({ taskId }: Props) {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [deciding, setDeciding] = React.useState<number | null>(null);
const load = React.useCallback(async () => {
try {
setRows(await api.interceptTask(taskId));
} catch {
// ignore
} finally {
setLoading(false);
}
}, [taskId]);
React.useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [load]);
async function decide(id: number, decision: "allowed" | "denied") {
if (deciding !== null) return;
setDeciding(id);
try {
await api.interceptDecide(id, decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
setRows((prev) =>
prev.map((r) => r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r),
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(null);
}
}
const pending = rows.filter((r) => r.status === "pending");
if (!loading && rows.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">此任务暂无拦截审批记录</p>
</div>
);
}
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col gap-4">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldAlertIcon className="h-4 w-4 text-muted-foreground" />
拦截审批
{pending.length > 0 && (
<Badge className="rounded-full bg-amber-500 text-white text-[10px] px-1.5">
{pending.length} 待审批
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className="mr-1 h-3.5 w-3.5" />
刷新
</Button>
</div>
{/* pending block */}
{pending.length > 0 && (
<div className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-2.5 text-xs font-medium text-amber-700 dark:text-amber-400">
<ShieldAlertIcon className="h-3.5 w-3.5" />
待处理({pending.length})— 超时 5 分钟后自动拒绝
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead className="w-28">Agent</TableHead>
<TableHead>工具</TableHead>
<TableHead>规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
<TableCell><AgentChip name={row.agent_name} /></TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "allowed")}>
<CheckIcon className="mr-1 h-3 w-3" />允许
</Button>
<Button size="sm" variant="destructive" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "denied")}>
<XIcon className="mr-1 h-3 w-3" />拒绝
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* history table */}
<div className="rounded-lg border">
<div className="border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
全部记录({rows.length})
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead className="w-28">Agent</TableHead>
<TableHead>工具</TableHead>
<TableHead>规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
<TableCell><AgentChip name={row.agent_name} /></TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.decided_at ? fmtTime(row.decided_at) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,244 @@
"use client";
import * as React from "react";
import {
ActivityIcon,
AlertTriangleIcon,
BugIcon,
ClockIcon,
ShieldCheckIcon,
TargetIcon,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
function StatCard({
label,
value,
sub,
icon: Icon,
}: {
label: string;
value: React.ReactNode;
sub?: string;
icon: React.ElementType;
}) {
return (
<Card className="gap-1.5">
<CardHeader className="pb-0">
<CardDescription className="flex items-center gap-1.5">
<Icon className="size-3.5" /> {label}
</CardDescription>
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
</CardHeader>
{sub && (
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
)}
</Card>
);
}
export function OverviewTab({ taskId }: { taskId: string }) {
const [task, setTask] = React.useState<Task | null>(null);
const [stats, setStats] = React.useState<Stats | null>(null);
const [intents, setIntents] = React.useState<TaskNode[]>([]);
const [findings, setFindings] = React.useState<Finding[]>([]);
React.useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const [tasksResp, statsResp, intentsResp, findingsResp] =
await Promise.all([
api.tasks(),
api.stats(taskId),
api.intents(taskId),
api.findings(taskId),
]);
if (cancelled) return;
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
setStats(statsResp);
setIntents(intentsResp);
setFindings(findingsResp);
} catch {
// transient errors are ignored; the next poll will retry
}
};
load();
const timer = setInterval(load, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId]);
const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open");
const blocked = intents.filter((i) => i.state === "blocked");
const taskFindings = findings.filter((f) => f.task_id === taskId);
const goalsPct = task?.goals_total
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
: 0;
return (
<div className="flex flex-col gap-4">
{/* Heartbeat */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ActivityIcon className="size-4 text-blue-500" /> 心跳
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<div className="text-xs text-muted-foreground">引擎态</div>
<StatusBadge
domain="engine"
value={stats?.engine_mode ?? task?.engine_mode ?? "idle"}
dot
className="mt-1"
/>
</div>
<div>
<div className="text-xs text-muted-foreground">运行中 Worker</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{running.length}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">最近活动</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" />
{task?.last_activity
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
: "—"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
目标 {task?.goals_met ?? 0}/{task?.goals_total ?? 0}
</div>
<Progress value={goalsPct} className="mt-2" />
</div>
{task?.completed_unix && task.completed_unix > 0 ? (
<div>
<div className="text-xs text-muted-foreground">完成时间</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" />
{new Date(task.completed_unix * 1000).toLocaleString("zh-CN")}
</div>
</div>
) : null}
</CardContent>
</Card>
{/* Work set */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<TargetIcon className="size-4" /> 进行中意图
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{running.slice(0, 6).map((i) => (
<div key={i.id} className="flex items-center gap-2 text-sm">
<StatusBadge domain="intent" value={i.state} />
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
</div>
))}
{running.length === 0 && (
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangleIcon className="size-4 text-amber-500" /> 需要关注
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{taskFindings.length}
</div>
<div className="text-xs text-muted-foreground">确认漏洞</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-blue-600">
{running.length}
</div>
<div className="text-xs text-muted-foreground">执行中</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums">
{open.length}
</div>
<div className="text-xs text-muted-foreground">frontier 待领</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{blocked.length}
</div>
<div className="text-xs text-muted-foreground">被拦意图</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<BugIcon className="size-4 text-red-500" /> 最近发现
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{taskFindings.slice(0, 6).map((f) => (
<div key={f.id} className="flex items-center gap-2 text-sm">
<StatusBadge domain="severity" value={f.severity} dot />
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
</div>
))}
{taskFindings.length === 0 && (
<p className="text-sm text-muted-foreground">暂无发现</p>
)}
</CardContent>
</Card>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
<StatCard
label="待领意图"
value={open.length}
icon={ShieldCheckIcon}
sub="frontier 开放"
/>
<StatCard
label="确认发现"
value={taskFindings.length}
icon={BugIcon}
sub="本任务"
/>
<StatCard
label="意图总数"
value={intents.length}
icon={AlertTriangleIcon}
sub="本任务全部意图"
/>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
export function ReportTab({ taskId }: { taskId: string }) {
const [report, setReport] = React.useState<string>("");
const [loading, setLoading] = React.useState(true);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
let active = true;
setLoading(true);
api
.report(taskId)
.then((text) => {
if (active) setReport(text);
})
.catch(() => {
if (active) setReport("");
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [taskId]);
function copy() {
if (!report) return;
navigator.clipboard?.writeText(report);
setCopied(true);
toast.success("已复制 Markdown");
setTimeout(() => setCopied(false), 1500);
}
return (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm">
<FileTextIcon className="size-4" /> 渗透测试报告(Markdown)
</CardTitle>
<div className="flex gap-2">
{report && (
<Button size="sm" variant="outline" onClick={copy}>
{copied ? <CheckIcon /> : <CopyIcon />} 复制
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
加载中…
</div>
) : report ? (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/40 p-4 font-mono text-xs whitespace-pre-wrap">
{report}
</pre>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
暂无报告
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,709 @@
"use client";
import * as React from "react";
import {
RadioIcon,
BrainIcon,
UserIcon,
Loader2Icon,
ZapOffIcon,
SendIcon,
SquareIcon,
ClockIcon,
CircleCheckIcon,
CircleXIcon,
CircleSlashIcon,
ShieldAlertIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api, sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type {
Activity,
InterceptApprovalRow,
Session,
SessionStatus,
TaskNode,
TokenTotal,
} from "@/lib/types";
// statusIcon maps a session status to its icon. Worker terminal states are
// distinct & color-coded: 完成(绿勾圈) / 取消停止(琥珀斜杠圈) / 出错(红叉圈) /
// 步数耗尽(紫). running=蓝色转圈, pending(待领取)=灰时钟.
function statusIcon(status: SessionStatus) {
switch (status) {
case "running": // 执行中
return <Loader2Icon className="size-3.5 animate-spin text-blue-500" />;
case "pending": // 待领取(open intent)
return <ClockIcon className="size-3.5 text-muted-foreground" />;
case "done": // 完成
return <CircleCheckIcon className="size-3.5 text-emerald-500" />;
case "stopped": // 取消/停止(被 planner 终止)
return <CircleSlashIcon className="size-3.5 text-amber-500" />;
case "blocked": // 出错
return <CircleXIcon className="size-3.5 text-red-500" />;
case "exhausted": // 步数耗尽(撞 max_turns)
return <ZapOffIcon className="size-3.5 text-violet-500" />;
}
}
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
const roleMeta = {
mainagent: { label: "主 Agent", icon: UserIcon },
planner: { label: "规划 Planner", icon: BrainIcon },
worker: { label: "Workers", icon: RadioIcon },
} as const;
// The main-agent session is the interactive entry point of this tab and has no
// dedicated backend "sessions" endpoint — it is a fixed UI affordance whose
// transcript is the full live activity stream for the task.
const MAIN_ID = "s-main";
const MAIN_SESSION: Session = {
id: MAIN_ID,
role: "mainagent",
title: "主 Agent · 编排会话",
status: "running",
live: true,
last_activity: "",
};
// The planner session is, like the main-agent session, a fixed UI affordance with
// no dedicated backend "sessions" endpoint — its transcript is every activity step
// the planner emits (worker === "planner", which carries no intent_id since the
// planner is what generates intents).
const PLANNER_ID = "s-planner";
const PLANNER_SESSION: Session = {
id: PLANNER_ID,
role: "planner",
title: "规划 Planner · 态势研判",
status: "running",
live: true,
last_activity: "",
};
// Map an exploration intent (TaskNode) state → a session status the UI renders.
function intentStatus(state: string): SessionStatus {
switch (state) {
case "done":
return "done";
case "blocked":
return "blocked";
case "exhausted":
return "exhausted";
case "stopped":
return "stopped";
case "open": // 待领取,区别于执行中
return "pending";
default: // running
return "running";
}
}
// Derive worker sessions from running/open intents — there is no backend
// sessions endpoint, so intents (≈ worker units) are the closest real source.
function intentToSession(n: TaskNode): Session {
const label = (n.payload ?? "").trim();
const state = intentStatus(n.state);
return {
id: n.id,
role: "worker",
title: label || `Intent ${n.id}`,
status: state,
live: state === "running",
last_activity: n.ts,
intent_id: n.id,
};
}
function SessionItem({
s,
active,
displayTitle,
hasPending,
onClick,
}: {
s: Session;
active: boolean;
displayTitle: string;
hasPending?: boolean;
onClick: () => void;
}) {
const icon =
s.role === "worker" ? (
statusIcon(s.status)
) : s.live ? (
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null;
return (
<button
onClick={onClick}
className={cn(
"flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{icon ?? <span className="size-3.5 shrink-0" />}
{s.intent_id && (
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
#{s.intent_id}
</span>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{hasPending && (
<ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />
)}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
</button>
);
}
export function SessionsTab({ taskId }: { taskId: string }) {
const [activeId, setActiveId] = React.useState(MAIN_ID);
// Live activity stream — the primary data for this tab.
const [allActivity, setAllActivity] = React.useState<Activity[]>([]);
// Worker sessions derived from exploration intents.
const [intents, setIntents] = React.useState<TaskNode[]>([]);
// Local-only chat messages overlaid onto the main-agent transcript.
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
// Whole-task token total (all agents), polled from the backend aggregate.
const [taskTokens, setTaskTokens] = React.useState<TokenTotal | null>(null);
// Pending intercept requests for this task — used to show warning icons on sessions.
const [pendingIntercepts, setPendingIntercepts] = React.useState<InterceptApprovalRow[]>([]);
React.useEffect(() => {
let alive = true;
const load = () =>
api
.tokenStats(taskId)
.then((r) => {
if (alive) setTaskTokens(r.total);
})
.catch(() => {});
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, [taskId]);
React.useEffect(() => {
let alive = true;
const load = () =>
api.interceptTask(taskId)
.then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); })
.catch(() => {});
load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
}, [taskId]);
// Re-render on a timer so "streaming" liveness recomputes as activity goes stale.
const [, setTick] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1500);
return () => clearInterval(id);
}, []);
// A session is "streaming" only if its agent emitted activity in the last few
// seconds (i.e. SSE actually in flight) — not a hardcoded always-on flag.
const STREAM_WINDOW_MS = 5000;
const streamingFor = React.useCallback(
(w: string) => {
let latest = 0;
for (const a of allActivity) {
if (a.worker === w) {
const t = Date.parse(a.ts);
if (t > latest) latest = t;
}
}
return latest > 0 && Date.now() - latest < STREAM_WINDOW_MS;
},
[allActivity],
);
const mainLive = streamingFor("mainagent");
const plannerLive = streamingFor("planner");
// Live activity stream via SSE: the endpoint replays history after since=0 then
// tails each new step. EventSource auto-reconnects; on reconnect history replays
// and the seq dedup below keeps it gap-free without duplicates.
React.useEffect(() => {
setAllActivity([]);
setLoaded(false);
// Mock demo:无 SSE 后端,一次性拉取活动快照(执行过程)。
if (MOCK) {
api
.activity(taskId)
.then((r) => setAllActivity(r.items))
.catch(() => {})
.finally(() => setLoaded(true));
return;
}
const es = new EventSource(
sseUrl(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=0`),
);
es.onopen = () => setLoaded(true);
es.onmessage = (e) => {
try {
const a = JSON.parse(e.data) as Activity;
setAllActivity((prev) =>
prev.some((x) => x.seq === a.seq)
? prev
: [...prev, a].sort((p, q) => p.seq - q.seq),
);
} catch {
/* ignore malformed frame */
}
};
return () => es.close();
}, [taskId]);
// Worker session list derives from exploration intents — poll lightly (changes
// far less often than the activity stream).
React.useEffect(() => {
let active = true;
const load = () =>
api.intents(taskId).then((nodes) => {
if (active) setIntents(nodes);
}).catch(() => {});
load();
const t = setInterval(load, 5000);
return () => {
active = false;
clearInterval(t);
};
}, [taskId]);
const workerSessions = React.useMemo(
() => intents.map(intentToSession),
[intents],
);
// For each worker session (intent), derive the display title from the latest
// activity summary (so the list shows what's actually happening, not the raw
// intent payload). Fall back to payload when no activity exists yet.
// Also store the full TaskNode for the hover-JSON tooltip.
const sessionMeta = React.useMemo(() => {
const map = new Map<string, { title: string; json: unknown }>();
for (const node of intents) {
let title = `Intent ${node.id}`;
let parsedPayload: unknown = node.payload;
if (node.payload) {
try {
const p = JSON.parse(node.payload);
parsedPayload = p;
if (p?.summary) title = String(p.summary);
} catch {
title = node.payload.trim() || title;
}
}
// Hover JSON: full node with payload replaced by parsed object for readability
const json = { ...node, payload: parsedPayload };
map.set(node.id, { title, json });
}
return map;
}, [intents]);
// Returns true if any pending intercept belongs to this session.
// Worker agent_name format: "work#N · #intentID". Main/planner match by role key.
const hasPendingForSession = React.useCallback(
(s: Session): boolean => {
if (!pendingIntercepts.length) return false;
if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent");
if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner");
// Worker: extract intent ID from "work#N · #<intentID>"
return pendingIntercepts.some((r) => {
const m = r.agent_name.match(/·\s*#(\d+)$/);
return m ? m[1] === s.intent_id : false;
});
},
[pendingIntercepts],
);
const sessions = React.useMemo(
() => [
{ ...MAIN_SESSION, live: mainLive },
{ ...PLANNER_SESSION, live: plannerLive },
...workerSessions,
],
[workerSessions, mainLive, plannerLive],
);
const grouped = {
mainagent: sessions.filter((s) => s.role === "mainagent"),
planner: sessions.filter((s) => s.role === "planner"),
worker: sessions.filter((s) => s.role === "worker"),
};
const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
const isMain = active.role === "mainagent";
const isPlanner = active.role === "planner";
// Main agent is the human↔orchestrator CONSOLE: it shows only the conversation
// (the user's messages + the main agent's own replies/steps), used to steer the
// run — NOT the planner/worker activity firehose (those live in their own
// sessions). The planner session shows only planner steps; a worker session
// shows only the activity for its intent.
const activity = React.useMemo(() => {
if (isMain) {
// The conversation is persisted server-side in the activity stream
// (worker="mainagent"), so it survives reloads. chatExtra is only an
// optimistic local echo — drop any entry the persisted (SSE) copy already
// covers, so messages don't show twice once the backend round-trips.
const mine = allActivity.filter((a) => a.worker === "mainagent");
const seen = new Set(mine.map((a) => `${a.kind}${a.summary}`));
const pending = chatExtra.filter(
(a) => !seen.has(`${a.kind}${a.summary}`),
);
return [...mine, ...pending].sort((a, b) => a.seq - b.seq);
}
if (isPlanner) {
return allActivity.filter((a) => a.worker === "planner");
}
// Worker session: the intent is NOT shown as the header title — instead it
// leads the transcript as a right-aligned "user"-style message (the task
// handed to this worker), followed by its execution steps.
const steps = allActivity.filter(
(a) => a.intent_id === active.intent_id || a.worker === active.id,
);
const intentTitle = sessionMeta.get(active.id)?.title ?? active.title;
const intentMsg: Activity = {
seq: -1, // sorts/leads before any real step (real seq ≥ 0)
worker: steps[0]?.worker ?? active.id, // reuse the lane so no worker chips appear
ts: active.last_activity || "",
kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble
summary: intentTitle,
};
return [intentMsg, ...steps];
}, [isMain, isPlanner, allActivity, chatExtra, active.intent_id, active.id, active.title, active.last_activity, sessionMeta]);
// seq of this session's most-recent TodoWrite call — for the Todo popover shown in
// the worker/planner footer (in place of the old read-only replay notice).
const latestTodoSeq = React.useMemo(() => {
for (let i = activity.length - 1; i >= 0; i--) {
const a = activity[i];
if (a.kind === "tool_use" && a.tool === "TodoWrite") return a.seq;
}
return null;
}, [activity]);
// Per-session token total, live. Each run (one captureRunSession) reports
// cumulative usage per model turn via kind='usage', then a final kind='result'.
// Total = sum of COMPLETED runs' results + the current in-progress run's latest
// usage. A 'result' adds to the sum and clears the live partial; a 'usage'
// overwrites the live partial. So a running work counts up live; a finished one
// shows its final result. (activity is seq-sorted.)
const tokenTotal = React.useMemo(() => {
let i = 0,
o = 0,
cr = 0,
cw = 0; // completed
let li = 0,
lo = 0,
lcr = 0,
lcw = 0; // live in-progress
for (const a of activity) {
if (a.kind === "result") {
i += a.input_tokens ?? 0;
o += a.output_tokens ?? 0;
cr += a.cache_read_tokens ?? 0;
cw += a.cache_write_tokens ?? 0;
li = lo = lcr = lcw = 0; // run finished → drop its live partial
} else if (a.kind === "usage") {
li = a.input_tokens ?? 0;
lo = a.output_tokens ?? 0;
lcr = a.cache_read_tokens ?? 0;
lcw = a.cache_write_tokens ?? 0;
}
}
const I = i + li,
O = o + lo,
CR = cr + lcr,
CW = cw + lcw;
return { i: I, o: O, cr: CR, cw: CW, any: I + O + CR + CW > 0 };
}, [activity]);
// Run duration = span from this session's first step to its last (for a live
// session, "now" so it ticks up — the 1.5s setTick above re-renders it). Derived
// purely from activity timestamps (Activity.ts), the only per-step time we have.
const runDuration = React.useMemo(() => {
let min = Infinity,
max = 0;
for (const a of activity) {
const t = Date.parse(a.ts);
if (!Number.isFinite(t)) continue;
if (t < min) min = t;
if (t > max) max = t;
}
if (!Number.isFinite(min) || max === 0) return null;
const end = active.live ? Date.now() : max;
return Math.max(0, end - min);
}, [activity, active.live]);
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
const viewport = React.useCallback(
() =>
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
null,
[],
);
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
};
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, activeId]);
// open/switch a session → jump to the latest (bottom)
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
vp.scrollTop = vp.scrollHeight;
atBottomRef.current = true;
}
}, [activeId, viewport]);
// new activity → stick to bottom only if the user is already pinned there
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [activity, viewport]);
function stop() {
if (stopping) return;
setStopping(true);
api.stopChat(taskId).finally(() => setStopping(false));
}
function send() {
const text = input.trim();
if (!text || sending) return;
const base = [...allActivity, ...chatExtra];
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
const userMsg: Activity = {
seq: nextSeq,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "user",
summary: text,
};
// optimistic echo of the human turn only; the agent's steps + final answer
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
setChatExtra((prev) => [...prev, userMsg]);
setInput("");
setSending(true);
api
.chat(text, taskId)
.catch(() => {
setChatExtra((prev) => [
...prev,
{
seq: nextSeq + 1,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "text",
summary: "(发送失败,请稍后重试)",
},
]);
})
.finally(() => setSending(false));
}
return (
<TooltipProvider delayDuration={300}>
<div className="grid h-[calc(100vh-13rem)] grid-cols-1 gap-4 lg:grid-cols-[18rem_1fr]">
{/* Left: session list */}
<div className="flex flex-col overflow-hidden rounded-lg border bg-card">
<div className="border-b px-3 py-2">
<div className="text-xs font-medium text-muted-foreground">会话列表</div>
{taskTokens && (
<div
className="mt-1 text-[11px] tabular-nums text-muted-foreground"
title="整个任务所有 agent 的 token 合计"
>
任务总计 · input {fmtTokens(taskTokens.input_tokens)} · cache{" "}
{fmtTokens(taskTokens.cache_read_tokens)} · output {fmtTokens(taskTokens.output_tokens)}
</div>
)}
</div>
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex w-full flex-col gap-3 p-2">
{(["mainagent", "planner", "worker"] as const).map((role) => {
const items = grouped[role];
if (!items.length) return null;
const Meta = roleMeta[role];
return (
<div key={role} className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
<Meta.icon className="size-3.5" />
{Meta.label}
</div>
{items.map((s) => {
const meta = s.role === "worker" ? sessionMeta.get(s.id) : undefined;
return (
<SessionItem
key={s.id}
s={s}
active={s.id === activeId}
displayTitle={meta?.title ?? s.title}
hasPending={hasPendingForSession(s)}
onClick={() => setActiveId(s.id)}
/>
);
})}
</div>
);
})}
{loaded && !workerSessions.length && (
<div className="px-2 py-1 text-xs text-muted-foreground">
暂无运行中的 Worker 会话。
</div>
)}
</div>
</ScrollArea>
</div>
{/* Right: transcript */}
<div className="flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-2.5">
{(() => {
const isWorker = active.role === "worker";
const meta = isWorker ? sessionMeta.get(active.id) : undefined;
// Worker: the intent moved into the transcript as a message, so the
// header shows a stable generic label (intent JSON stays on hover).
const title = isWorker ? "Worker 执行会话" : active.title;
const titleEl = <span className="text-sm font-medium">{title}</span>;
return meta?.json ? (
<Tooltip>
<TooltipTrigger asChild>{titleEl}</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0">
<pre className="p-2 text-[10px] leading-relaxed">
{JSON.stringify(meta.json, null, 2)}
</pre>
</TooltipContent>
</Tooltip>
) : titleEl;
})()}
{active.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
{tokenTotal.any && (
<span title="input / cache(read) / output tokens">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
)}
{runDuration != null && (
<span
className="inline-flex items-center gap-1"
title="运行时长(首步 → 末步)"
>
<ClockIcon className="size-3" />
{fmtDuration(runDuration)}
</span>
)}
{isMain && <span>可交互</span>}
</div>
</div>
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
out the width and defeat the truncation below — the transcript wraps to
the panel instead of overflowing horizontally. */}
<ScrollArea
type="auto"
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
>
<div className="min-w-0 max-w-full p-4" ref={contentRef}>
{!loaded ? (
<div className="flex items-center gap-2 pl-9 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载活动流…
</div>
) : activity.length ? (
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} />
) : (
<div className="pl-9 text-xs text-muted-foreground">
{isMain
? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。"
: "暂无活动记录。"}
</div>
)}
</div>
</ScrollArea>
{isMain ? (
<div className="flex items-center gap-2 border-t p-3">
<Input
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !mainLive) send();
}}
disabled={mainLive}
/>
{mainLive ? (
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</Button>
) : (
<Button size="icon" onClick={send} disabled={!input.trim() || sending}>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</Button>
)}
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
<TodoPopover
seq={latestTodoSeq}
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
/>
</div>
)}
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeftIcon,
PauseIcon,
PlayIcon,
BrainIcon,
} from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Task } from "@/lib/types";
import { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { ReportTab } from "./_tabs/report-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
import { AssetsTab } from "./_tabs/assets-tab";
const TABS = [
{ value: "sessions", label: "会话" },
{ value: "overview", label: "总览" },
{ value: "graph", label: "探索链路" },
{ value: "findings", label: "发现" },
{ value: "assets", label: "测试资产" },
{ value: "intercept", label: "拦截审批" },
{ value: "report", label: "报告" },
];
function TaskDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const [task, setTask] = React.useState<Task | null>(null);
const [paused, setPaused] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
React.useEffect(() => {
let alive = true;
const load = () =>
api.interceptTask(id)
.then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); })
.catch(() => {});
load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
}, [id]);
const load = React.useCallback(() => {
Promise.all([
api.tasks(),
api.stats(id).catch(() => null),
])
.then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null;
const at = (s as { active_task?: Partial<Task> & { paused?: boolean } } | null)?.active_task;
if (base && at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
base.engine_mode = at.engine_mode;
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base?.paused ?? false);
})
.catch(() => {})
.finally(() => setLoaded(true));
}, [id]);
React.useEffect(() => { load(); }, [load]);
async function togglePause() {
const next = !paused;
try {
await api.controlTask(id, next ? "pause" : "resume");
setPaused(next);
toast.success(next ? "已暂停探索" : "已恢复探索");
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
if (!task) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
<p className="text-muted-foreground">{loaded ? `未找到任务 ${id}` : "加载中…"}</p>
{loaded && (
<Button asChild variant="outline">
<Link href="/function/tasks">
<ArrowLeftIcon /> 返回任务列表
</Link>
</Button>
)}
</div>
);
}
const engineMode = paused ? "paused" : task.engine_mode ?? "idle";
return (
<Tabs
value={tab}
onValueChange={setTab}
className="flex flex-1 flex-col gap-0"
>
{/* Top fixed area */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button asChild variant="ghost" size="icon" className="size-7">
<Link href="/function/tasks">
<ArrowLeftIcon />
</Link>
</Button>
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}>
{task.description}
</h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
{task.id}
</code>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* status pills */}
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs">
<BrainIcon className="size-3.5 text-emerald-500" /> LLM 已配置
</span>
<StatusBadge domain="engine" value={engineMode} dot />
<div className="ml-auto">
<Button
size="sm"
variant={paused ? "default" : "outline"}
onClick={togglePause}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{paused ? "恢复" : "暂停"}
</Button>
</div>
</div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{/* Tabs */}
<TabsList variant="default">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
{t.value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</header>
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
<TabsContent value="sessions" className="mt-0">
<SessionsTab taskId={id} />
</TabsContent>
<TabsContent value="overview" className="mt-0">
<OverviewTab taskId={id} />
</TabsContent>
<TabsContent value="graph" className="mt-0">
<GraphTab taskId={id} />
</TabsContent>
<TabsContent value="findings" className="mt-0">
<FindingsTab taskId={id} />
</TabsContent>
<TabsContent value="assets" className="mt-0">
<AssetsTab taskId={id} />
</TabsContent>
<TabsContent value="intercept" className="mt-0">
<InterceptTab taskId={id} />
</TabsContent>
<TabsContent value="report" className="mt-0">
<ReportTab taskId={id} />
</TabsContent>
</div>
</Tabs>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function TaskDetailPage() {
return (
<React.Suspense fallback={null}>
<TaskDetailInner />
</React.Suspense>
);
}
+428
View File
@@ -0,0 +1,428 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { toast } from "sonner";
import {
PlusIcon,
Trash2Icon,
ArrowRightIcon,
StarIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { api } from "@/lib/api";
import type { Task, TaskStatus, LLMProfile } from "@/lib/types";
// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile".
const ACTIVE_PROFILE = "__active__";
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders a run duration in seconds as a compact human string.
function fmtDuration(sec: number): string {
if (sec <= 0) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// taskDuration is the task's run duration in seconds: created → now while running,
// created → completion for a finished task, else created → last activity. 0 when it
// never ran (no activity yet).
function taskDuration(task: Task, nowSec: number): number {
const start = task.created_unix ?? 0;
if (!start) return 0;
const end =
task.status === "running"
? nowSec
: task.completed_unix && task.completed_unix > 0
? task.completed_unix
: task.last_activity_unix ?? 0;
return end > start ? end - start : 0;
}
// fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string {
if (!unix || unix <= 0) return "—";
const d = new Date(unix * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
{ value: "created", label: "已创建" },
{ value: "running", label: "运行中" },
{ value: "paused", label: "已暂停" },
{ value: "done", label: "已完成" },
{ value: "failed", label: "失败" },
{ value: "timeout", label: "已超时" },
];
export default function TasksPage() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [llmProfile, setLlmProfile] = React.useState<string>(ACTIVE_PROFILE); // sentinel = active
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
if (statusFilter !== "all" && t.status !== statusFilter) return false;
if (!q) return true;
return (
t.description.toLowerCase().includes(q) ||
t.goal.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q)
);
});
}, [tasks, query, statusFilter]);
// reset to page 1 whenever filters change
React.useEffect(() => { setPage(1); }, [query, statusFilter]);
const paginated = React.useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
);
const load = React.useCallback(() => {
api.tasks()
.then((r) => {
setTasks(r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)));
})
.catch(() => {});
}, []);
React.useEffect(() => {
load();
const i = setInterval(load, 3000);
return () => clearInterval(i);
}, [load]);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
api.llmProfiles().then(setProfiles).catch(() => setProfiles([]));
}, []);
// tick every second so running tasks' 运行时长 counts up live.
React.useEffect(() => {
const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(i);
}, []);
async function createTask() {
if (!description.trim() || !goal.trim()) {
toast.error("请填写描述与目标");
return;
}
try {
const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile);
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
await api.createTask(description.trim(), goal.trim(), pid, timeoutSec);
toast.success("任务已创建");
setDescription("");
setGoal("");
setLlmProfile(ACTIVE_PROFILE);
setTimeoutMin("");
setOpen(false);
load();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
}
}
async function deleteTask(id: string) {
try {
await api.deleteTask(id);
toast.success("任务已删除(全局资产图保留)");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
<div className="flex flex-wrap items-center gap-2 px-4 lg:px-6">
<div className="relative w-full sm:max-w-xs">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="搜索描述 / 目标 / ID"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>新建任务</DialogTitle>
<DialogDescription>
填写测试对象与目标。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={createTask}>创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{tasks.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
暂无任务,点击右上角「新建任务」开始。
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
没有匹配的任务。
</div>
) : (
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="font-mono">ID</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-right">创建时间</TableHead>
<TableHead className="text-right">运行时长</TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((task) => (
<TableRow key={task.id} className="group border-border/60">
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<span className="truncate" title={task.description}>
{task.description}
</span>
{task.active && (
<StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
</TableCell>
<TableCell className="text-muted-foreground text-center text-xs tabular-nums">
{typeof task.goals_total === "number" && task.goals_total > 0
? `${task.goals_met}/${task.goals_total}`
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs whitespace-nowrap tabular-nums">
{fmtDateTime(task.created_unix)}
</TableCell>
<TableCell className="text-right text-xs whitespace-nowrap tabular-nums">
{(() => {
const secs = taskDuration(task, nowSec);
if (secs <= 0) return <span className="text-muted-foreground">—</span>;
return (
<span className={task.status === "running" ? "text-foreground" : "text-muted-foreground"}>
{fmtDuration(secs)}
</span>
);
})()}
</TableCell>
<TableCell
className="text-right text-xs whitespace-nowrap tabular-nums"
title={
task.tokens
? `输入 ${task.tokens.input_tokens} · 缓存 ${task.tokens.cache_read_tokens} · 输出 ${task.tokens.output_tokens}`
: undefined
}
>
{task.tokens ? (
<span className="text-muted-foreground">
入 <span className="text-foreground">{fmtTokens(task.tokens.input_tokens)}</span>
{" · 缓 "}
<span className="text-foreground">{fmtTokens(task.tokens.cache_read_tokens)}</span>
{" · 出 "}
<span className="text-foreground">{fmtTokens(task.tokens.output_tokens)}</span>
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-2">
<Button asChild size="sm">
<Link href={`/function/tasks/detail?id=${task.id}`}>
进入 <ArrowRightIcon />
</Link>
</Button>
<Button
size="icon"
variant="outline"
onClick={() => deleteTask(task.id)}
aria-label="删除任务"
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,383 @@
"use client";
import * as React from "react";
import {
RadioTowerIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
XIcon,
Loader2Icon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { TrafficExchange, TrafficResp, TrafficDetail } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function fmtBytes(n: number) {
if (n <= 0) return "0 B";
const units = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
const v = n / Math.pow(1024, i);
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
}
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
if (status >= 400) return "text-amber-500";
if (status >= 300) return "text-blue-500";
if (status >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
// Fixed method set (server filters exact-match); avoids deriving options from a
// single page, which would only ever list the methods on that page.
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
const PAGE_SIZES = [25, 50, 100, 200];
export default function TrafficPage() {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [host, setHost] = React.useState(""); // raw host input
const [hostQ, setHostQ] = React.useState(""); // debounced → server
const [query, setQuery] = React.useState(""); // raw free-text input
const [queryQ, setQueryQ] = React.useState(""); // debounced → server
const [method, setMethod] = React.useState("all");
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Debounce both filters so we don't refetch on every keystroke.
React.useEffect(() => {
const t = setTimeout(() => setHostQ(host.trim()), 300);
return () => clearTimeout(t);
}, [host]);
React.useEffect(() => {
const t = setTimeout(() => setQueryQ(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Any filter/size change resets to the first page.
React.useEffect(() => {
setPage(0);
}, [hostQ, queryQ, method, size]);
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
// through history isn't yanked out from under the user.
React.useEffect(() => {
let alive = true;
const load = () =>
api
.traffic(page, size, hostQ, method, queryQ)
.then((r) => {
if (alive) setTraffic(r);
})
.catch(() => {});
load();
const t = page === 0 ? setInterval(load, 5000) : null;
return () => {
alive = false;
if (t) clearInterval(t);
};
}, [page, size, hostQ, method, queryQ]);
// Lazy-load the raw request/response for the selected exchange.
React.useEffect(() => {
if (!selected) {
setDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setDetail(null);
api
.trafficExchange(selected.id)
.then((d) => {
if (alive) setDetail(d);
})
.catch(() => {
if (alive) setDetail({ req: "(无法加载报文)", resp: "" });
})
.finally(() => {
if (alive) setDetailLoading(false);
});
return () => {
alive = false;
};
}, [selected]);
const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]);
const total = traffic?.total ?? exchanges.length;
const pageCount = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + exchanges.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
<p className="text-muted-foreground text-sm">
全局录制代理 · 所有 HTTP 往来
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
traffic?.enabled
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-transparent bg-muted text-muted-foreground",
)}
>
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? "录制中" : "已停用"}
</span>
{traffic?.proxy && (
<span className="font-mono text-xs text-muted-foreground">
{traffic.proxy}
</span>
)}
<span className="text-xs text-muted-foreground">
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
</span>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-48">
<Input
placeholder="host…"
value={host}
onChange={(e) => setHost(e.target.value)}
className="h-8"
/>
</div>
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部方法</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + detail (Burp-style split) */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-36">时间</TableHead>
<TableHead className="w-44">host</TableHead>
<TableHead className="w-20">方法</TableHead>
<TableHead>URL</TableHead>
<TableHead className="w-20">状态码</TableHead>
<TableHead className="w-36">content-type</TableHead>
<TableHead className="w-24 text-right">响应长度</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn(
"cursor-pointer",
selected?.id === e.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(e)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(e.ts)}
</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{e.method}
</Badge>
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">
{e.url}
</span>
</TableCell>
<TableCell>
<span
className={cn(
"font-mono text-xs font-semibold tabular-nums",
statusTone(e.status),
)}
>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{e.content_type}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{fmtBytes(e.resp_len)}
</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="py-12 text-center text-sm text-muted-foreground"
>
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
{/* Raw request/response pane */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="font-mono text-xs">
{selected.method}
</Badge>
<span className="truncate font-mono text-xs">
{selected.host}
{selected.url}
</span>
<span
className={cn(
"ml-auto font-mono text-xs font-semibold tabular-nums",
statusTone(selected.status),
)}
>
{selected.status}
</span>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
{(
[
["请求 Request", detail?.req],
["响应 Response", detail?.resp],
] as const
).map(([label, body]) => (
<div key={label} className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
{label}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{body || "(空)"}
</pre>
)}
</div>
</div>
))}
</div>
</Card>
)}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import type { ReactNode } from "react";
import * as React from "react";
import { AppSidebar } from "@/app/(main)/_components/sidebar/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { getClientCookie } from "@/lib/cookie.client";
import {
SIDEBAR_COLLAPSIBLE_VALUES,
SIDEBAR_VARIANT_VALUES,
type SidebarCollapsible,
type SidebarVariant,
} from "@/lib/preferences/layout";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { cn } from "@/lib/utils";
import { MainContent } from "./_components/main-content";
// Reads a layout-critical preference from the browser cookie (falls back to the
// default during static-export prerender where document is unavailable). Kept
// fully client-side so the app can be statically exported — no Server Actions /
// next/headers.
function readPref<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
if (typeof document === "undefined") return fallback;
const value = getClientCookie(key);
return value && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
}
export default function Layout({ children }: Readonly<{ children: ReactNode }>) {
// Client-side auth gate — replaces the Next proxy/middleware that static export
// disables. No token → bounce to /login; render nothing until confirmed so no
// protected UI (or its API calls) flashes for a logged-out visitor.
const [authed, setAuthed] = React.useState(false);
React.useEffect(() => {
if (auth.getToken()) {
setAuthed(true);
} else {
window.location.href = "/login";
}
}, []);
const defaultOpen = typeof document === "undefined" ? true : getClientCookie("sidebar_state") !== "false";
const variant = readPref<SidebarVariant>(
"sidebar_variant",
SIDEBAR_VARIANT_VALUES,
PREFERENCE_DEFAULTS.sidebar_variant,
);
const collapsible = readPref<SidebarCollapsible>(
"sidebar_collapsible",
SIDEBAR_COLLAPSIBLE_VALUES,
PREFERENCE_DEFAULTS.sidebar_collapsible,
);
if (!authed) return null;
return (
<SidebarProvider
defaultOpen={defaultOpen}
style={
{
"--sidebar-width": "calc(var(--spacing) * 68)",
} as React.CSSProperties
}
>
<AppSidebar variant={variant} collapsible={collapsible} />
<SidebarInset
className={cn(
"[html[data-content-layout=centered]_&>*]:mx-auto",
"[html[data-content-layout=centered]_&>*]:w-full",
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
"peer-data-[variant=inset]:border",
"[--dashboard-header-height:--spacing(12)]",
"min-w-0 overflow-x-hidden",
)}
>
<MainContent>{children}</MainContent>
</SidebarInset>
</SidebarProvider>
);
}
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { AgentEditor } from "@/components/agent-editor";
import { Button } from "@/components/ui/button";
// Deep-link editor page for one agent. The primary flow is the drawer on
// /system/agents; this page reuses the same AgentEditor component full-width so a
// direct URL (or an external link) still opens the editor.
function AgentDetailInner() {
const searchParams = useSearchParams();
const key = searchParams.get("key") ?? "";
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon-sm">
<Link href="/system/agents">
<ArrowLeftIcon className="size-4" />
</Link>
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Agent</h1>
<p className="text-muted-foreground font-mono text-xs">{key}</p>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-lg border">
<AgentEditor agentKey={key} />
</div>
</div>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function AgentDetailPage() {
return (
<React.Suspense fallback={null}>
<AgentDetailInner />
</React.Suspense>
);
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Bot, PlusIcon, Trash2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { AgentEditor } from "@/components/agent-editor";
import { api } from "@/lib/api";
import type { Agent } from "@/lib/types";
// AgentGridCard is one clickable tile opening the agent's editor drawer. Custom
// (non-builtin) agents get a delete button.
function AgentGridCard({
agent,
onOpen,
onDeleted,
}: {
agent: Agent;
onOpen: () => void;
onDeleted: () => void;
}) {
async function del() {
try {
await api.deleteAgent(agent.key);
toast.success(`已删除 Agent「${agent.name}」`);
onDeleted();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
return (
<div className="hover:border-primary/50 group relative flex flex-col gap-2 rounded-lg border p-4 transition-colors">
<button type="button" onClick={onOpen} className="flex flex-col gap-2 text-left">
<div className="flex flex-wrap items-center gap-2">
<Bot className="text-muted-foreground size-4" />
<span className="text-sm font-medium">{agent.name}</span>
<span className="text-muted-foreground font-mono text-xs">{agent.key}</span>
{agent.builtin ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
内置
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
{!agent.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
已停用
</Badge>
)}
</div>
<p className="text-muted-foreground line-clamp-2 min-h-8 text-xs">
{agent.description || "(无描述)"}
</p>
<div className="text-muted-foreground flex flex-wrap gap-1.5 text-[10px]">
<span className="rounded border px-1.5 py-0.5">MCP {agent.mcp_count ?? 0}</span>
<span className="rounded border px-1.5 py-0.5">Skill {agent.skill_count ?? 0}</span>
<span className="rounded border px-1.5 py-0.5">工具 {agent.tool_count ?? 0}</span>
</div>
</button>
{!agent.builtin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2Icon className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除 Agent「{agent.name}」?</AlertDialogTitle>
<AlertDialogDescription>
将一并删除它的提示词、变量、可见性与工具绑定。此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={del}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
}
function CreateAgentDialog({ onCreated }: { onCreated: (key: string) => void }) {
const [open, setOpen] = React.useState(false);
const [key, setKey] = React.useState("");
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [busy, setBusy] = React.useState(false);
async function create() {
setBusy(true);
try {
const a = await api.createAgent(key.trim(), name.trim(), description.trim());
toast.success(`已创建 Agent「${a.name}」`);
setOpen(false);
setKey("");
setName("");
setDescription("");
onCreated(a.key);
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
} finally {
setBusy(false);
}
}
const keyOk = /^[a-z][a-z0-9_]*$/.test(key.trim());
const canCreate = keyOk && name.trim().length > 0 && !busy;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon /> 新建 Agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新建自定义 Agent</DialogTitle>
<DialogDescription>
创建一个会话型助手。key 用于内部标识,创建后不可更改;名称与描述用于识别。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="agent-key">Key</Label>
<Input
id="agent-key"
placeholder="如 research_helper"
value={key}
onChange={(e) => setKey(e.target.value)}
className="font-mono"
/>
{key.length > 0 && !keyOk && (
<span className="text-destructive text-xs">小写字母开头,仅含小写字母/数字/下划线</span>
)}
</div>
<div className="grid gap-1.5">
<Label htmlFor="agent-name">名称</Label>
<Input
id="agent-name"
placeholder="如 研究助手"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="agent-desc">描述</Label>
<Textarea
id="agent-desc"
placeholder="一句话说明这个 Agent 是干什么的"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button onClick={create} disabled={!canCreate}>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AgentsPage() {
const [agents, setAgents] = React.useState<Agent[]>([]);
const [editKey, setEditKey] = React.useState<string | null>(null);
const reload = React.useCallback(() => {
api.agents().then(setAgents).catch(() => setAgents([]));
}, []);
React.useEffect(() => {
reload();
}, [reload]);
const editing = agents.find((a) => a.key === editKey) ?? null;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">Agent</h1>
<p className="text-muted-foreground text-sm">
内置 Agent 的提示词/配置,以及自定义会话 Agent 的创建与管理
</p>
</div>
<CreateAgentDialog
onCreated={(key) => {
reload();
setEditKey(key);
}}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Agent 清单</CardTitle>
<CardDescription>共 {agents.length} 个</CardDescription>
</CardHeader>
<CardContent>
{agents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">(暂无 Agent)</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((a) => (
<AgentGridCard key={a.key} agent={a} onOpen={() => setEditKey(a.key)} onDeleted={reload} />
))}
</div>
)}
</CardContent>
</Card>
<Sheet open={!!editing} onOpenChange={(o) => !o && setEditKey(null)}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw]"
>
{editing && (
<>
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
{editing.name}
<span className="text-muted-foreground font-mono text-xs">{editing.key}</span>
{!editing.builtin && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
</SheetTitle>
<SheetDescription>{editing.description || "提示词、配置、可见资源与工具绑定"}</SheetDescription>
</SheetHeader>
<AgentEditor agentKey={editing.key} onSaved={reload} />
</>
)}
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,244 @@
"use client";
import * as React from "react";
import { CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
// ---- helpers ----------------------------------------------------------------
function fmtTime(s: string) {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function SourceCell({ row }: { row: InterceptApprovalRow }) {
if (row.task_id) {
return (
<div className="space-y-0.5">
<div className="font-mono text-xs font-medium">{row.task_id}</div>
<div className="text-[11px] text-muted-foreground">{row.agent_name || "—"}</div>
</div>
);
}
if (row.conversation_id) {
const agent = row.conv_agent_key || "—";
const title = row.conv_title || `对话 #${row.conversation_id}`;
return (
<div className="space-y-0.5">
<div className="font-medium">{title}</div>
<div className="text-[11px] text-muted-foreground">Agent: {agent}</div>
</div>
);
}
return <span className="text-muted-foreground">—</span>;
}
function StatusBadge({ status }: { status: string }) {
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
const short = s.length > 120 ? s.slice(0, 120) + "…" : s;
return (
<Tooltip>
<TooltipTrigger asChild>
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">
{short}
</code>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
</TooltipContent>
</Tooltip>
);
}
// ---- main component ---------------------------------------------------------
export default function ApprovalsPage() {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [deciding, setDeciding] = React.useState<number | null>(null);
async function load() {
try {
const items = await api.interceptHistory();
setRows(items);
} catch (e) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
React.useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
async function decide(id: number, decision: "allowed" | "denied") {
if (deciding !== null) return;
setDeciding(id);
try {
await api.interceptDecide(id, decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
setRows((prev) =>
prev.map((r) =>
r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r,
),
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(null);
}
}
const pending = rows.filter((r) => r.status === "pending");
const decided = rows.filter((r) => r.status !== "pending");
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-1 flex-col gap-6 p-6">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardListIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold">审批记录</h1>
{pending.length > 0 && (
<Badge className="rounded-full bg-destructive text-destructive-foreground">{pending.length} 待审批</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className="mr-1.5 h-3.5 w-3.5" />
刷新
</Button>
</div>
{/* pending section */}
{pending.length > 0 && (
<section className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-3 text-sm font-medium text-amber-700 dark:text-amber-400">
<ShieldAlertIcon className="h-4 w-4" />
待处理 ({pending.length})
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>工具</TableHead>
<TableHead>来源</TableHead>
<TableHead>匹配规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-28 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground">{row.id}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><SourceCell row={row} /></TableCell>
<TableCell>
{row.rule_name
? <span className="text-sm">{row.rule_name}</span>
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
className="h-7 px-2 text-xs"
disabled={deciding === row.id}
onClick={() => decide(row.id, "allowed")}
>
<CheckIcon className="mr-1 h-3 w-3" />
允许
</Button>
<Button
size="sm"
variant="destructive"
className="h-7 px-2 text-xs"
disabled={deciding === row.id}
onClick={() => decide(row.id, "denied")}
>
<XIcon className="mr-1 h-3 w-3" />
拒绝
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)}
{/* history section */}
<section className="rounded-lg border">
<div className="border-b px-4 py-3 text-sm font-medium text-muted-foreground">
全部记录 ({rows.length})
</div>
{rows.length === 0 && !loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">暂无审批记录</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>工具</TableHead>
<TableHead>来源</TableHead>
<TableHead>匹配规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
<TableCell className="text-muted-foreground">{row.id}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><SourceCell row={row} /></TableCell>
<TableCell>
{row.rule_name
? <span className="text-sm">{row.rule_name}</span>
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.decided_at ? fmtTime(row.decided_at) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,633 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
ListFilterIcon,
PencilIcon,
PlusIcon,
ShieldAlertIcon,
Trash2Icon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/lib/api";
import type { InterceptRule, InterceptAction, Tool } from "@/lib/types";
// ---- tool scope ----
// SDK tools are intentionally not seeded into the DB (they apply to every agent
// and have no per-agent binding). We hardcode them here so they still appear in
// the scope dialog.
function sdkTool(key: string, description: string): Tool {
return { key, system: true, description, schema: {}, agents: [], enabled: true, kind: "builtin" };
}
const SDK_EXEC: Tool[] = [
sdkTool("Bash", "在 shell 中执行命令"),
sdkTool("WebFetch", "发起 HTTP/HTTPS 请求(含代理支持)"),
sdkTool("web_search", "网络搜索"),
sdkTool("shell_open", "开启持久 PTY 交互会话"),
sdkTool("shell_send", "向交互会话发送输入"),
sdkTool("shell_read", "读取交互会话输出"),
sdkTool("shell_close", "关闭交互会话"),
sdkTool("shell_list", "列出所有交互会话"),
];
const SDK_WRITE: Tool[] = [
sdkTool("Write", "写入文件"),
sdkTool("Edit", "编辑文件(精确替换)"),
sdkTool("MultiEdit", "批量编辑文件"),
];
const SDK_KEYS = new Set([...SDK_EXEC, ...SDK_WRITE].map((t) => t.key));
function groupTools(dbTools: Tool[]) {
const sys: Tool[] = [], custom: Tool[] = [];
for (const t of dbTools) {
if (SDK_KEYS.has(t.key)) continue; // already covered by hardcoded groups
if (t.system) sys.push(t);
else custom.push(t);
}
return [
{ label: "执行类", tools: SDK_EXEC },
{ label: "写入/编辑类", tools: SDK_WRITE },
{ label: "系统工具", tools: sys },
{ label: "自定义工具", tools: custom },
].filter((g) => g.tools.length > 0);
}
// ---- form state ----
type RuleForm = {
name: string;
enabled: boolean;
priority: number;
match_target: "tool_name" | "tool_input";
match_type: "string" | "regex";
pattern: string;
action: InterceptAction;
message: string;
timeout_enabled: boolean;
timeout_seconds: number;
timeout_action: "deny" | "allow";
};
const defaultForm = (): RuleForm => ({
name: "",
enabled: true,
priority: 0,
match_target: "tool_name",
match_type: "string",
pattern: "",
action: "deny",
message: "",
timeout_enabled: true,
timeout_seconds: 60,
timeout_action: "deny",
});
// ---- small components ----
function ActionBadge({ action }: { action: InterceptAction }) {
if (action === "allow") return <Badge variant="secondary">允许</Badge>;
if (action === "deny") return <Badge variant="destructive">禁止</Badge>;
return <Badge variant="outline" className="border-amber-400 text-amber-600">申请</Badge>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</Label>
{children}
</div>
);
}
// ---- page ----
export default function InterceptPage() {
const [rules, setRules] = React.useState<InterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<InterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const [regexErr, setRegexErr] = React.useState("");
const [regexWarn, setRegexWarn] = React.useState(false); // true = JS 无法解析但可能是合法 Go 语法
// ---- tool scope dialog ----
const [scopeOpen, setScopeOpen] = React.useState(false);
const [allTools, setAllTools] = React.useState<Tool[]>([]);
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
const [scopeLoading, setScopeLoading] = React.useState(false);
const [scopeSaving, setScopeSaving] = React.useState(false);
// ---- data ----
const load = React.useCallback(async () => {
try {
const r = await api.interceptRules();
setRules(r);
} catch {
toast.error("加载拦截规则失败");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
if (form.match_type !== "regex" || !form.pattern) { setRegexErr(""); setRegexWarn(false); return; }
try {
new RegExp(form.pattern);
setRegexErr("");
setRegexWarn(false);
} catch {
// JS RegExp 不支持 Go RE2 扩展语法(如 (?i) 内联 flag)。
// 这里只是预览校验失败,不代表 Go 端无效;交给服务端最终验证。
setRegexErr("");
setRegexWarn(true);
}
}, [form.pattern, form.match_type]);
// ---- rule handlers ----
function set(patch: Partial<RuleForm>) { setForm(f => ({ ...f, ...patch })); }
function openNew() {
setEditing(null);
setForm(defaultForm());
setRegexErr("");
setOpen(true);
}
function openEdit(rule: InterceptRule) {
setEditing(rule);
setForm({
name: rule.name, enabled: rule.enabled, priority: rule.priority,
match_target: rule.match_target, match_type: rule.match_type,
pattern: rule.pattern, action: rule.action, message: rule.message,
timeout_enabled: rule.timeout_enabled, timeout_seconds: rule.timeout_seconds,
timeout_action: rule.timeout_action,
});
setRegexErr("");
setOpen(true);
}
async function handleSave() {
if (!form.name.trim()) { toast.error("名称不能为空"); return; }
if (!form.pattern.trim()) { toast.error("模式不能为空"); return; }
if (regexErr) { toast.error("正则表达式语法无效"); return; }
setSaving(true);
try {
if (editing) {
await api.updateInterceptRule(editing.id, form);
toast.success("规则已更新");
} else {
await api.createInterceptRule(form);
toast.success("规则已创建");
}
setOpen(false);
load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
try {
await api.deleteInterceptRule(id);
toast.success("规则已删除");
load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: InterceptRule) {
try {
await api.toggleInterceptRule(rule.id, !rule.enabled);
load();
} catch (e) {
toast.error((e as Error).message);
}
}
// ---- scope handlers ----
async function openScope() {
setScopeOpen(true);
setScopeLoading(true);
try {
const [tools, cfg] = await Promise.all([api.tools(), api.interceptGetToolConfig()]);
setAllTools(tools);
setEnabledTools(new Set(cfg.enabled_tools));
} catch (e) {
toast.error("加载失败: " + (e as Error).message);
} finally {
setScopeLoading(false);
}
}
function toggleTool(key: string, val: boolean) {
setEnabledTools(prev => {
const next = new Set(prev);
if (val) next.add(key); else next.delete(key);
return next;
});
}
async function saveScope() {
setScopeSaving(true);
try {
await api.interceptSetToolConfig([...enabledTools]);
toast.success("拦截范围已保存");
setScopeOpen(false);
} catch (e) {
toast.error("保存失败: " + (e as Error).message);
} finally {
setScopeSaving(false);
}
}
const toolGroups = React.useMemo(() => groupTools(allTools), [allTools]);
// ---- render ----
return (
<div className="flex flex-1 flex-col gap-6 p-6">
{/* ---- header ---- */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2.5">
<ShieldAlertIcon className="h-5 w-5 mt-0.5 shrink-0" />
<div>
<h1 className="text-lg font-semibold leading-tight">拦截规则</h1>
<p className="text-sm text-muted-foreground mt-0.5">
工具执行前按优先级(数字越大越先)逐条匹配,首条命中的规则生效
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={openScope}>
<ListFilterIcon className="h-4 w-4" />
拦截范围
</Button>
<Button onClick={openNew} size="sm">
<PlusIcon className="h-4 w-4" />
新建规则
</Button>
</div>
</div>
{/* ---- rules table ---- */}
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
) : rules.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无规则</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
新建第一条规则
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">优先级</TableHead>
<TableHead>名称</TableHead>
<TableHead className="w-[90px]">目标</TableHead>
<TableHead className="w-[80px]">类型</TableHead>
<TableHead>模式</TableHead>
<TableHead className="w-[72px]">策略</TableHead>
<TableHead className="w-[64px] text-center">启用</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<span className="font-mono text-xs tabular-nums">{rule.priority}</span>
</TableCell>
<TableCell className="font-medium text-sm">{rule.name}</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_target === "tool_name" ? "工具名" : "输入内容"}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_type === "regex" ? "正则" : "字符串"}
</span>
</TableCell>
<TableCell className="max-w-[220px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell>
<ActionBadge action={rule.action} />
</TableCell>
<TableCell className="text-center">
<Switch
checked={rule.enabled}
onCheckedChange={() => handleToggle(rule)}
/>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon" variant="ghost" className="h-7 w-7"
onClick={() => openEdit(rule)}
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon" variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule.id)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ---- editor sheet ---- */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? "编辑规则" : "新建规则"}</SheetTitle>
<SheetDescription className="text-xs">
优先级越大越先匹配;首条命中规则生效,后续跳过
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label="名称">
<Input
placeholder="给规则起个名字"
value={form.name}
onChange={(e) => set({ name: e.target.value })}
/>
</Field>
<Field label="优先级(数字越大越先匹配)">
<Input
type="number"
value={form.priority}
onChange={(e) => set({ priority: parseInt(e.target.value) || 0 })}
/>
</Field>
<Separator />
<Field label="匹配目标">
<Select
value={form.match_target}
onValueChange={(v) => set({ match_target: v as RuleForm["match_target"] })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tool_name">工具名(tool_name)</SelectItem>
<SelectItem value="tool_input">输入内容(tool_input JSON)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="匹配类型">
<Select
value={form.match_type}
onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="string">字符串包含</SelectItem>
<SelectItem value="regex">正则表达式</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="模式">
<Input
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{regexErr && (
<p className="text-xs text-destructive mt-1">{regexErr}</p>
)}
{regexWarn && (
<p className="text-xs text-amber-600 mt-1">包含 Go RE2 扩展语法(如 <code className="font-mono">(?i)</code>),浏览器无法预览,提交后由服务端验证</p>
)}
</Field>
<Separator />
<Field label="拦截策略">
<Select
value={form.action}
onValueChange={(v) => set({ action: v as InterceptAction })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="allow">允许 — 直接放行,跳过后续规则</SelectItem>
<SelectItem value="deny">禁止 — 阻断,返回拒绝消息给模型</SelectItem>
<SelectItem value="ask">向用户申请 — 等待审批</SelectItem>
</SelectContent>
</Select>
</Field>
{form.action !== "allow" && (
<Field label={form.action === "deny" ? "拒绝消息(返回给模型)" : "审批说明(可选)"}>
<Textarea
placeholder={form.action === "deny" ? "操作被安全策略阻止" : ""}
value={form.message}
onChange={(e) => set({ message: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
)}
{form.action === "ask" && (
<>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">启用审批超时</p>
<p className="text-xs text-muted-foreground">超时后自动处置,不再等待</p>
</div>
<Switch
checked={form.timeout_enabled}
onCheckedChange={(v) => set({ timeout_enabled: v })}
/>
</div>
{form.timeout_enabled && (
<div className="flex items-end gap-3">
<Field label="超时时间(秒)">
<Input
type="number"
min={5}
className="w-28"
value={form.timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) set({ timeout_seconds: n });
}}
/>
</Field>
<Field label="超时动作">
<Select
value={form.timeout_action}
onValueChange={(v) => set({ timeout_action: v as "deny" | "allow" })}
>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deny">自动拒绝</SelectItem>
<SelectItem value="allow">自动允许</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
</>
)}
<Separator />
<div className="flex items-center gap-3">
<Switch
id="rule-enabled"
checked={form.enabled}
onCheckedChange={(v) => set({ enabled: v })}
/>
<Label htmlFor="rule-enabled" className="cursor-pointer">启用此规则</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving || !!regexErr}>
{saving ? "保存中…" : "保存"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ---- scope dialog ---- */}
<Dialog open={scopeOpen} onOpenChange={setScopeOpen}>
<DialogContent className="sm:max-w-lg flex flex-col overflow-hidden p-0 gap-0" style={{ maxHeight: "min(80vh, 560px)" }}>
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<ListFilterIcon className="h-4 w-4" />
拦截范围
</DialogTitle>
<DialogDescription className="text-xs">
只有启用拦截的工具才会进入规则匹配;其余工具直接放行
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-5">
{scopeLoading ? (
<p className="text-sm text-muted-foreground py-4">加载中…</p>
) : (
toolGroups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <Separator className="mb-5" />}
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{group.label}
</p>
<div className="space-y-0.5">
{group.tools.map((t) => (
<div key={t.key} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Switch
id={`scope-${t.key}`}
checked={enabledTools.has(t.key)}
onCheckedChange={(v) => toggleTool(t.key, v)}
/>
<label htmlFor={`scope-${t.key}`} className="flex-1 min-w-0 cursor-pointer">
<div className="flex items-center gap-1.5">
<span className="font-mono text-sm">{t.key}</span>
{(t.kind && t.kind !== "builtin") && (
<Badge variant="outline" className="px-1 py-0 text-[10px]">{t.kind}</Badge>
)}
</div>
{t.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1">{t.description}</p>
)}
</label>
</div>
))}
</div>
</div>
))
)}
</div>
<div className="shrink-0 border-t px-6 py-3 flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setScopeOpen(false)}>取消</Button>
<Button size="sm" onClick={saveScope} disabled={scopeSaving || scopeLoading}>
{scopeSaving ? "保存中…" : "保存"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+692
View File
@@ -0,0 +1,692 @@
"use client";
import * as React from "react";
import { Loader2Icon, PlugZapIcon, PlusIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { api } from "@/lib/api";
import type { LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// 思考强度档位(仅在开关打开时生效)。
const EFFORT_LEVELS: { value: string; label: string }[] = [
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "max", label: "最大" },
];
// 思考模式三态 ↔ 存库字符串 reasoning_effort:
// none→""(thinking / reasoning_effort 两个字段都【不发送】,兼容不支持该字段的模型,如 MiniMax)
// off →"off"(发送 thinking:{type:disabled},用于默认开启思考、需要显式关闭的模型)
// on →强度值(发送 thinking:{enabled} + reasoning_effort)
type ThinkMode = "none" | "off" | "on";
const THINK_MODES: { value: ThinkMode; label: string }[] = [
{ value: "none", label: "不发送(默认)" },
{ value: "off", label: "显式关闭" },
{ value: "on", label: "开启" },
];
const toStore = (mode: ThinkMode, effort: string) => (mode === "on" ? effort : mode === "off" ? "off" : "");
const modeFromStore = (v?: string): ThinkMode => (!v ? "none" : v === "off" ? "off" : "on");
const effortFromStore = (v?: string) => (v && v !== "off" ? v : "high");
function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic");
const [model, setModel] = React.useState("");
const [baseUrl, setBaseUrl] = React.useState("");
const [proxy, setProxy] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [rps, setRps] = React.useState("0");
const [rpm, setRpm] = React.useState("0");
const [cw, setCw] = React.useState("0");
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
function reset() {
setName("");
setFormat("anthropic");
setModel("");
setBaseUrl("");
setProxy("");
setApiKey("");
setRps("0");
setRpm("0");
setCw("0");
setThinkMode("none");
setEffort("high");
}
async function create() {
if (!name.trim() || !model.trim()) {
toast.error("请填写名称与模型");
return;
}
try {
const { id } = await api.saveLLMProfile({
name: name.trim(),
format,
model: model.trim(),
base_url: baseUrl.trim(),
proxy: proxy.trim(),
api_key: apiKey,
rate_per_second: Number(rps) || 0,
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
});
toast.success(`已新建 Profile:${name.trim()}(在列表中「设为激活」以启用)`);
reset();
setOpen(false);
onCreated(String(id));
} catch (e) {
toast.error(`新建失败:${(e as Error).message}`);
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset();
}}
>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<PlusIcon /> 新建
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>新建 Profile</DialogTitle>
<DialogDescription>新建后不会自动激活,请在列表中「设为激活」以启用。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="np-name">名称</Label>
<Input
id="np-name"
placeholder="例如:OpenAI 生产"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label>格式</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
<SelectTrigger>
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="np-model">模型</Label>
<Input
id="np-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="np-base-url">Base URL(可选)</Label>
<Input
id="np-base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="np-proxy">代理(可选)</Label>
<Input
id="np-proxy"
className="font-mono"
placeholder="http://127.0.0.1:8080 · socks5://127.0.0.1:1080"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="np-api-key">API Key</Label>
<Input
id="np-api-key"
type="password"
placeholder="sk-…"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="np-rps">每秒限速</Label>
<Input id="np-rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="np-rpm">每分钟限速</Label>
<Input id="np-rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="np-cw">上下文窗口(K)</Label>
<Input
id="np-cw"
type="number"
min={0}
max={1000}
value={cw}
onChange={(e) => setCw(e.target.value)}
placeholder="200"
/>
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 disabled(默认开思考的模型才需要);开启=先推理再作答。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINK_MODES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{thinkMode === "on" && (
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">思考强度</Label>
<p className="text-muted-foreground text-xs">推理投入档位,越高越深入。</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue placeholder="强度" />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={create}>新建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function LLMPage() {
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic");
const [name, setName] = React.useState("");
const [model, setModel] = React.useState("");
const [baseUrl, setBaseUrl] = React.useState("");
const [proxy, setProxy] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [keyHint, setKeyHint] = React.useState("");
const [rps, setRps] = React.useState("0");
const [rpm, setRpm] = React.useState("0");
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [testing, setTesting] = React.useState(false);
const load = React.useCallback(async () => {
try {
const ps = await api.llmProfiles();
setProfiles(ps);
setSelectedId((prev) => {
if (prev && ps.some((p) => p.id === prev)) return prev;
return ps.find((p) => p.is_default)?.id ?? ps[0]?.id ?? null;
});
} catch {
/* ignore */
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
// Fill the editor from the selected profile. Runs only on explicit reloads /
// selection changes (this page does not poll), so it never clobbers edits.
const selected = React.useMemo(() => profiles.find((p) => p.id === selectedId) ?? null, [profiles, selectedId]);
React.useEffect(() => {
if (!selected) return;
setName(selected.name);
setFormat(selected.format === "anthropic" ? "anthropic" : "openai");
setModel(selected.model);
setBaseUrl(selected.base_url ?? "");
setProxy(selected.proxy ?? "");
setRps(String(selected.rate_per_second ?? 0));
setRpm(String(selected.rate_per_minute ?? 0));
setCw(String(selected.context_window_k ?? 0));
setThinkMode(modeFromStore(selected.reasoning_effort));
setEffort(effortFromStore(selected.reasoning_effort));
setApiKey("");
setKeyHint(selected.api_key_hint ?? "");
}, [selected]);
async function testConnection() {
if (testing) return;
setTesting(true);
try {
// test with the SAME thinking params the profile will run with, so an
// unsupported reasoning field fails here instead of only at run time.
// 传当前选中 profile 的 id:输入框留空时,后端用该 profile 已存的 key 测试。
const r = await api.testLLM(
format,
model,
baseUrl,
apiKey,
proxy,
toStore(thinkMode, effort),
selectedId ? Number(selectedId) : undefined,
);
if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`);
else toast.error(`连接失败:${r.error ?? "未知"}`);
} catch (e) {
toast.error(`测试出错:${(e as Error).message}`);
} finally {
setTesting(false);
}
}
async function save() {
if (!selectedId) {
toast.error("请先在左侧选择或新建一个 Profile");
return;
}
if (!name.trim() || !model.trim()) {
toast.error("请填写名称与模型");
return;
}
try {
await api.saveLLMProfile({
id: Number(selectedId),
name: name.trim(),
format,
model: model.trim(),
base_url: baseUrl.trim(),
proxy: proxy.trim(),
api_key: apiKey,
rate_per_second: Number(rps) || 0,
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
});
toast.success(selected?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
setApiKey("");
await load();
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
}
}
async function activate(id: string) {
try {
await api.activateLLMProfile(id);
const p = profiles.find((x) => x.id === id);
toast.success(`已激活 Profile:${p?.name ?? id}`);
await load();
} catch (e) {
toast.error(`激活失败:${(e as Error).message}`);
}
}
async function remove(p: LLMProfile) {
if (p.is_default) {
toast.error("无法删除当前激活的 Profile");
return;
}
try {
await api.deleteLLMProfile(p.id);
toast.success(`已删除 Profile:${p.name}`);
await load();
} catch (e) {
toast.error(`删除失败:${(e as Error).message}`);
}
}
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="font-semibold text-xl tracking-tight">LLM</h1>
<p className="text-muted-foreground text-sm">全 Agent 共享的格式 / 模型 / 限速配置</p>
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-3">
<Card className="lg:order-2 lg:col-span-2">
<CardHeader>
<CardTitle>
{selected ? (
<span className="flex items-center gap-2">
编辑 Profile:{selected.name}
{selected.is_default && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
激活中
</Badge>
)}
</span>
) : (
"格式配置"
)}
</CardTitle>
<CardDescription>
{selected
? "修改后点击「保存」。若为激活 Profile,保存后对全部 Agent 立即生效。"
: "从左侧选择一个 Profile 进行编辑,或点击「新建」。"}
</CardDescription>
</CardHeader>
{selected ? (
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">名称</Label>
<Input
id="name"
placeholder="Profile 名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>格式</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
<SelectTrigger>
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="model">模型</Label>
<Input
id="model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="base-url">Base URL(可选)</Label>
<Input
id="base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy">代理(可选)</Label>
<Input
id="proxy"
className="font-mono"
placeholder="http://127.0.0.1:8080 · socks5://127.0.0.1:1080"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
仅 LLM 出站请求走此代理,支持 http/https/socks5;留空则用环境变量(HTTP_PROXY/HTTPS_PROXY)。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="api-key">API Key</Label>
<Input
id="api-key"
type="password"
placeholder={keyHint ? `已设置(${keyHint}),留空保持不变` : "sk-…"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="rps">每秒限速</Label>
<Input id="rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="rpm">每分钟限速</Label>
<Input id="rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
<p className="text-muted-foreground text-xs">0 = 不限</p>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="cw">上下文窗口(K tokens)</Label>
<Input
id="cw"
type="number"
min={0}
max={1000}
value={cw}
onChange={(e) => setCw(e.target.value)}
placeholder="200"
/>
<p className="text-muted-foreground text-xs">
模型上下文窗口,用于压缩阈值。单位 K(千 token);0/留空 = 默认 200K;上限 1000(即
1M)。设太高会导致压缩不触发。
</p>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 disabled(默认开思考的模型才需要);开启=先推理再作答。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINK_MODES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{thinkMode === "on" && (
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">思考强度</Label>
<p className="text-muted-foreground text-xs">
推理投入档位,越高越深入(OpenAI reasoning_effort / Anthropic output_config.effort)。
</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue placeholder="强度" />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={testConnection} disabled={testing}>
{testing ? <Loader2Icon className="animate-spin" /> : <PlugZapIcon />}
{testing ? "测试中…" : "测试连接"}
</Button>
<Button onClick={save}>
<SaveIcon /> 保存
</Button>
</div>
</CardContent>
) : (
<CardContent>
<div className="flex items-center justify-center rounded-lg border border-dashed py-16 text-muted-foreground text-sm">
还没有 Profile,点击左侧「新建」创建第一个。
</div>
</CardContent>
)}
</Card>
<Card className="lg:order-1">
<CardHeader className="has-data-[slot=card-action]:grid-cols-[1fr_auto]">
<CardTitle>Profiles</CardTitle>
<CardDescription>点击选中以编辑;全 Agent 用同一激活 Profile,限速全局共享。</CardDescription>
<CardAction className="self-center">
<NewProfileDialog
onCreated={(id) => {
setSelectedId(id);
void load();
}}
/>
</CardAction>
</CardHeader>
<CardContent className="grid gap-3">
{profiles.map((p) => (
// biome-ignore lint/a11y/useSemanticElements: row wraps its own action buttons, so a native <button> would nest buttons (invalid HTML)
<div
key={p.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(p.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedId(p.id);
}
}}
aria-pressed={p.id === selectedId}
className={cn(
"cursor-pointer rounded-lg border p-3 text-left outline-none transition-colors",
p.is_default && "border-amber-400/50 bg-amber-400/5",
p.id === selectedId ? "ring-2 ring-primary" : "hover:border-foreground/30",
)}
>
<div className="flex items-start gap-2">
<StarIcon
className={cn(
"mt-0.5 size-4 shrink-0",
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="uppercase">
{p.format}
</Badge>
</div>
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground text-xs">
<span>{p.api_key_hint}</span>
<span>
{p.rate_per_second}/s · {p.rate_per_minute}/min
</span>
{p.proxy && <span className="truncate">代理 {p.proxy}</span>}
{p.reasoning_effort && (
<span>思考 {p.reasoning_effort === "off" ? "关" : p.reasoning_effort}</span>
)}
</div>
</div>
</div>
<div className="mt-2 flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={p.is_default}
onClick={(e) => {
e.stopPropagation();
void activate(p.id);
}}
>
{p.is_default ? "已激活" : "设为激活"}
</Button>
<Button
size="icon"
variant="outline"
aria-label="删除 Profile"
onClick={(e) => {
e.stopPropagation();
void remove(p);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
))}
{profiles.length === 0 && (
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground text-sm">
暂无 Profile
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type { LogLine } from "@/lib/types";
import { cn } from "@/lib/utils";
// Mock demo:无后端 SSE,塞几行示例日志。
const MOCK_LOGS: LogLine[] = [
{ seq: 1, ts: "2026-07-26T03:55:00Z", level: "info", tag: "engine", text: "ARTEX v0.1.0 backend listening on :8787 (workers=3)" },
{ seq: 2, ts: "2026-07-26T03:55:01Z", level: "info", tag: "config", text: "LLM configured from DB: anthropic / claude-opus-4-8" },
{ seq: 3, ts: "2026-07-26T03:56:10Z", level: "info", tag: "planner", text: "task t-acme-web: 第 3 轮规划,生成意图 i-4" },
{ seq: 4, ts: "2026-07-26T03:57:00Z", level: "warn", tag: "guard", text: "block bash: 目标越界 out.evil.example 不在 scope 内" },
{ seq: 5, ts: "2026-07-26T03:57:30Z", level: "info", tag: "work#1", text: "report_finding: Default Credentials (high) 已落库" },
{ seq: 6, ts: "2026-07-26T03:58:20Z", level: "error", tag: "work#3", text: "intercept: mysqldump 命中破坏性规则,等待人工审批" },
];
const levelTone: Record<LogLine["level"], string> = {
info: "text-foreground/80",
warn: "text-amber-600 dark:text-amber-400",
error: "text-red-600 dark:text-red-400",
};
const levelDot: Record<LogLine["level"], string> = {
info: "bg-muted-foreground/40",
warn: "bg-amber-500",
error: "bg-red-500",
};
function fmtTime(ts: string) {
const d = new Date(ts);
return isNaN(d.getTime()) ? "" : d.toLocaleTimeString("zh-CN", { hour12: false });
}
export default function LogsPage() {
const [lines, setLines] = React.useState<LogLine[]>([]);
const [q, setQ] = React.useState("");
const [level, setLevel] = React.useState<"all" | LogLine["level"]>("all");
const [paused, setPaused] = React.useState(false);
const [loadingHistory, setLoadingHistory] = React.useState(false);
const [hasMore, setHasMore] = React.useState(false);
const bottom = React.useRef<HTMLDivElement>(null);
const stick = React.useRef(true);
const pausedRef = React.useRef(false);
pausedRef.current = paused;
// Minimum db_id seen — used as the cursor for loading older history.
const minDbId = React.useMemo(() => {
let min = 0;
for (const l of lines) {
if (l.db_id && (min === 0 || l.db_id < min)) min = l.db_id;
}
return min;
}, [lines]);
// Live tail via SSE. on connect, replays whatever is in the ring (includes
// DB-restored history from the last 100 rows written before restart).
React.useEffect(() => {
if (MOCK) {
setLines(MOCK_LOGS);
return;
}
const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
es.onmessage = (e) => {
if (pausedRef.current) return;
try {
const l = JSON.parse(e.data) as LogLine;
setLines((prev) => (prev.some((x) => x.seq === l.seq) ? prev : [...prev, l].slice(-3000)));
} catch {
/* ignore malformed frame */
}
};
return () => es.close();
}, []);
// Mark "has more" once we have any db_id in view.
React.useEffect(() => {
if (minDbId > 1) setHasMore(true);
}, [minDbId]);
async function loadOlderHistory() {
if (loadingHistory) return;
setLoadingHistory(true);
try {
const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`;
const res = await fetch(`/api/logs/history${params}`);
if (!res.ok) return;
const data = await res.json() as { items: LogLine[]; has_more: boolean };
if (data.items?.length) {
// Assign synthetic seq numbers below current minimum to keep dedup working.
setLines((prev) => {
const minSeq = prev.reduce((m, l) => Math.min(m, l.seq ?? 0), 0);
const older = data.items.map((l, i) => ({
...l,
seq: minSeq - data.items.length + i,
}));
// Prepend, dedup by db_id, cap at 5000.
const merged = [...older, ...prev];
const seen = new Set<number>();
const deduped = merged.filter((l) => {
if (!l.db_id) return true;
if (seen.has(l.db_id)) return false;
seen.add(l.db_id);
return true;
});
return deduped.slice(-5000);
});
}
setHasMore(data.has_more ?? false);
} finally {
setLoadingHistory(false);
}
}
const filtered = React.useMemo(() => {
const needle = q.trim().toLowerCase();
return lines.filter(
(l) =>
(level === "all" || l.level === level) &&
(!needle || l.text.toLowerCase().includes(needle) || l.tag.toLowerCase().includes(needle)),
);
}, [lines, q, level]);
React.useEffect(() => {
if (stick.current && !paused) bottom.current?.scrollIntoView();
}, [filtered, paused]);
const counts = React.useMemo(() => {
let warn = 0,
error = 0;
for (const l of lines) {
if (l.level === "warn") warn++;
else if (l.level === "error") error++;
}
return { warn, error, total: lines.length };
}, [lines]);
return (
<div className="flex flex-1 flex-col gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">系统日志</h1>
<p className="text-muted-foreground text-sm">后端实时日志流(planner / worker / 数据库 / 流量 …)</p>
</div>
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="过滤(文本 / tag)…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="h-8 max-w-xs"
/>
<div className="flex gap-1">
{(["all", "info", "warn", "error"] as const).map((lv) => (
<Button
key={lv}
size="sm"
variant={level === lv ? "default" : "outline"}
className="h-8"
onClick={() => setLevel(lv)}
>
{lv === "all" ? "全部" : lv}
</Button>
))}
</div>
<Button
size="sm"
variant={paused ? "default" : "outline"}
className="h-8"
onClick={() => setPaused((p) => !p)}
>
{paused ? "已暂停" : "暂停"}
</Button>
<Button size="sm" variant="outline" className="h-8" onClick={() => setLines([])}>
清空
</Button>
<span className="ml-auto text-xs text-muted-foreground">
{counts.total} 行 ·{" "}
<span className="text-amber-600 dark:text-amber-400">{counts.warn} 警告</span> ·{" "}
<span className="text-red-600 dark:text-red-400">{counts.error} 错误</span>
</span>
</div>
<div
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className="h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-card p-2 font-mono text-xs leading-relaxed"
>
{hasMore && (
<div className="flex justify-center py-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={loadingHistory}
onClick={loadOlderHistory}
>
{loadingHistory ? "加载中…" : "加载更早日志"}
</Button>
</div>
)}
{filtered.length === 0 ? (
<p className="py-10 text-center text-muted-foreground">暂无日志。</p>
) : (
filtered.map((l) => {
const body =
l.tag && l.text.startsWith("[" + l.tag + "]")
? l.text.slice(l.tag.length + 2).trimStart()
: l.text;
return (
<div key={l.seq} className="flex items-start gap-2 px-1 py-0.5 hover:bg-muted/40">
<span className={cn("mt-1.5 size-1.5 shrink-0 rounded-full", levelDot[l.level])} />
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtTime(l.ts)}</span>
{l.tag && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-foreground/70">{l.tag}</span>
)}
<span className={cn("min-w-0 whitespace-pre-wrap break-words", levelTone[l.level])}>{body}</span>
</div>
);
})
)}
<div ref={bottom} />
</div>
</div>
</div>
);
}
+460
View File
@@ -0,0 +1,460 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { PlusIcon, RefreshCwIcon, ServerIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { MCPServer, MCPTool, Agent } from "@/lib/types";
type Transport = "stdio" | "http";
type FormState = {
name: string;
transport: Transport;
command: string;
args: string;
url: string;
env: string;
};
const emptyForm: FormState = {
name: "",
transport: "stdio",
command: "",
args: "",
url: "",
env: "",
};
export default function MCPPage() {
const [servers, setServers] = React.useState<MCPServer[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [visibility, setVisibility] = React.useState<Record<number, string[]>>({});
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<MCPServer | null>(null); // null = add mode
const [tab, setTab] = React.useState<"config" | "tools">("config");
const [form, setForm] = React.useState<FormState>(emptyForm);
const [saving, setSaving] = React.useState(false);
const [tools, setTools] = React.useState<MCPTool[]>([]);
const [toolsLoading, setToolsLoading] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const load = React.useCallback(() => {
api.agents().then(setAgents).catch(() => {});
api
.mcpServers()
.then((ss) => {
setServers(ss);
ss.forEach((s) =>
api
.resourceVisibility("mcp", s.id)
.then((ids) => setVisibility((v) => ({ ...v, [s.id]: ids })))
.catch(() => {}),
);
})
.catch(() => {});
}, []);
React.useEffect(() => {
load();
}, [load]);
function setF(patch: Partial<FormState>) {
setForm((f) => ({ ...f, ...patch }));
}
function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf("=");
if (idx > 0) out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
}
return out;
}
function envToText(env: Record<string, string> | undefined): string {
return Object.entries(env ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function openAdd() {
setEditing(null);
setForm(emptyForm);
setTools([]);
setTab("config");
setOpen(true);
}
function openEdit(s: MCPServer) {
setEditing(s);
setForm({
name: s.name,
transport: s.transport,
command: s.command ?? "",
args: (s.args ?? []).join(" "),
url: s.url ?? "",
env: envToText(s.env),
});
setTab("config");
setOpen(true);
loadTools(s.id);
}
async function loadTools(id: number) {
setToolsLoading(true);
try {
setTools(await api.mcpTools(id));
} catch {
setTools([]);
} finally {
setToolsLoading(false);
}
}
async function saveForm() {
if (!form.name.trim()) {
toast.error("请填写名称");
return;
}
if (form.transport === "stdio" && !form.command.trim()) {
toast.error("请填写命令");
return;
}
if (form.transport === "http" && !form.url.trim()) {
toast.error("请填写远程 URL");
return;
}
setSaving(true);
try {
const base =
form.transport === "http"
? {
transport: "http" as const,
url: form.url.trim(),
command: "",
args: [] as string[],
env: parseEnv(form.env), // 远程模式下 env 即请求头
}
: {
transport: "stdio" as const,
command: form.command.trim(),
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
env: parseEnv(form.env),
};
await api.saveMcpServer({
...(editing ? { id: editing.id } : {}),
name: form.name.trim(),
enabled: editing ? editing.enabled : true,
...base,
});
toast.success(editing ? "已保存" : "已添加 MCP 服务器");
if (!editing) setOpen(false);
load();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function refreshTools() {
if (!editing) return;
setRefreshing(true);
try {
const t = await api.refreshMcpServer(editing.id);
setTools(t);
toast.success(`发现 ${t.length} 个工具`);
load();
} catch (e) {
toast.error("刷新失败:" + (e as Error).message);
} finally {
setRefreshing(false);
}
}
async function removeServer(s: MCPServer) {
try {
await api.deleteMcpServer(s.id);
toast.success(`已删除:${s.name}`);
setOpen(false);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function toggleEnabled(s: MCPServer) {
try {
await api.saveMcpServer({ ...s, enabled: !s.enabled });
load();
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
async function toggleVisibility(serverId: number, agentId: string, agentName: string) {
const on = (visibility[serverId] ?? []).includes(agentId);
try {
await api.toggleVisibility(agentId, "mcp", serverId, !on);
toast.success(`${on ? "取消" : "授予"}「${agentName}」可见`);
load();
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
function renderForm() {
return (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>传输方式</Label>
<div className="flex gap-2">
<Button
type="button"
variant={form.transport === "stdio" ? "default" : "outline"}
onClick={() => setF({ transport: "stdio" })}
>
stdio(本地)
</Button>
<Button
type="button"
variant={form.transport === "http" ? "default" : "outline"}
onClick={() => setF({ transport: "http" })}
>
http(远程)
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="m-name">名称</Label>
<Input
id="m-name"
placeholder="filesystem"
value={form.name}
onChange={(e) => setF({ name: e.target.value })}
/>
</div>
{form.transport === "stdio" ? (
<>
<div className="grid gap-2">
<Label htmlFor="m-cmd">命令</Label>
<Input
id="m-cmd"
className="font-mono"
placeholder="npx"
value={form.command}
onChange={(e) => setF({ command: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="m-args">参数(空格分隔)</Label>
<Input
id="m-args"
className="font-mono"
placeholder="-y @modelcontextprotocol/server-filesystem /data"
value={form.args}
onChange={(e) => setF({ args: e.target.value })}
/>
</div>
</>
) : (
<div className="grid gap-2">
<Label htmlFor="m-url">远程 URL</Label>
<Input
id="m-url"
className="font-mono"
placeholder="https://mcp.example.com/mcp"
value={form.url}
onChange={(e) => setF({ url: e.target.value })}
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="m-env">
{form.transport === "http"
? "请求头(每行 KEY=VALUE,如 Authorization=Bearer xxx)"
: "环境变量(每行 KEY=VALUE)"}
</Label>
<Textarea
id="m-env"
className="font-mono"
placeholder={
form.transport === "http" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"
}
value={form.env}
onChange={(e) => setF({ env: e.target.value })}
/>
</div>
</div>
);
}
function renderTools() {
return (
<div className="flex flex-col gap-3 py-4">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">{tools.length} 个工具</span>
<Button size="sm" variant="outline" disabled={refreshing} onClick={refreshTools}>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} /> 刷新
</Button>
</div>
{toolsLoading ? (
<p className="text-muted-foreground text-sm">加载中…</p>
) : tools.length === 0 ? (
<p className="text-muted-foreground text-sm">尚未发现工具,点击刷新重新获取。</p>
) : (
<div className="flex flex-col divide-y">
{tools.map((t) => (
<div key={t.name} className="py-2.5">
<code className="font-mono text-sm">{t.name}</code>
{t.description && (
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
{t.description}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">MCP</h1>
<p className="text-muted-foreground text-sm">外部 MCP 工具服务器 · 按 Agent 授权可见</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<button
type="button"
onClick={openAdd}
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[116px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
>
<PlusIcon className="size-6" />
<span className="text-sm">添加 MCP</span>
</button>
{servers.map((s) => (
<Card
key={s.id}
onClick={() => openEdit(s)}
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
>
<CardHeader>
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<CardTitle className="truncate text-base">{s.name}</CardTitle>
<Badge variant="outline" className="uppercase">
{s.transport}
</Badge>
<div className="ml-auto flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Switch
checked={s.enabled}
onCheckedChange={() => toggleEnabled(s)}
aria-label="启用"
/>
<Button
size="icon"
variant="outline"
aria-label="删除"
onClick={() => removeServer(s)}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<p className="text-muted-foreground text-sm">
{s.tools && s.tools.length > 0 ? `${s.tools.length} 个工具` : "尚未发现工具"}
</p>
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-muted-foreground text-xs">可见性(按 Agent 授权)</span>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={(visibility[s.id] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(s.id, a.id, a.name)}
/>
{a.name}
</label>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-full data-[side=right]:sm:max-w-lg"
>
<SheetHeader>
<SheetTitle>{editing ? editing.name : "添加 MCP 服务器"}</SheetTitle>
<SheetDescription>
stdio(本地起进程)或 http(远程 Streamable HTTP)
</SheetDescription>
</SheetHeader>
{editing ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "config" | "tools")}
className="flex min-h-0 flex-1 flex-col px-4"
>
<TabsList>
<TabsTrigger value="config">配置</TabsTrigger>
<TabsTrigger value="tools">
工具列表{tools.length ? `(${tools.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="min-h-0 flex-1 overflow-y-auto">
{renderForm()}
<div className="flex gap-2 pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
保存
</Button>
</div>
</TabsContent>
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto">
{renderTools()}
</TabsContent>
</Tabs>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{renderForm()}
<div className="pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
<PlusIcon /> 添加
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
+429
View File
@@ -0,0 +1,429 @@
"use client";
import * as React from "react";
import { CpuIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import type { Settings } from "@/lib/types";
export default function SystemSettingsPage() {
const [trafficCapture, setTrafficCapture] = React.useState(false);
const [webSearch, setWebSearch] = React.useState(false);
const [backend, setBackend] = React.useState("ddgs");
const [braveKeySet, setBraveKeySet] = React.useState(false);
const [braveKeyInput, setBraveKeyInput] = React.useState("");
const [tavilyKeySet, setTavilyKeySet] = React.useState(false);
const [tavilyKeyInput, setTavilyKeyInput] = React.useState("");
const [savingTavilyKey, setSavingTavilyKey] = React.useState(false);
const [proxyInput, setProxyInput] = React.useState("");
const [savingProxy, setSavingProxy] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [savingKey, setSavingKey] = React.useState(false);
const [pyInterp, setPyInterp] = React.useState("");
const [workers, setWorkers] = React.useState("3");
const [savingWorkers, setSavingWorkers] = React.useState(false);
const apply = React.useCallback((s: Settings) => {
setTrafficCapture(!!s.traffic_capture);
setWebSearch(!!s.web_search_enabled);
setBackend(s.web_search_backend || "ddgs");
setBraveKeySet(!!s.brave_key_set);
setTavilyKeySet(!!s.tavily_key_set);
setProxyInput(s.web_search_proxy || "");
setPyInterp(s.python_interpreter || "");
setWorkers(String(s.workers ?? 3));
}, []);
const saveWorkers = () => {
const n = Number(workers);
if (!Number.isInteger(n) || n <= 0) {
toast.error("并发数必须是大于 0 的整数");
return;
}
setSavingWorkers(true);
api
.setSettings({ workers: n })
.then((s) => {
apply(s);
toast.success("已保存并发工作 agent 数(对之后启动的任务生效)");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingWorkers(false));
};
const savePython = () => {
setSaving(true);
api
.setSettings({ python_interpreter: pyInterp.trim() })
.then((s) => {
apply(s);
toast.success("已保存 Python 解释器配置");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSaving(false));
};
const detectPython = () => {
setSaving(true);
api.detectPython().then((r) => setPyInterp(r.python_interpreter)).catch(() => undefined).finally(() => setSaving(false));
};
React.useEffect(() => {
api
.settings()
.then(apply)
.catch(() => undefined)
.finally(() => setLoaded(true));
}, [apply]);
const toggleTraffic = (v: boolean) => {
setTrafficCapture(v); // optimistic
setSaving(true);
api
.setSettings({ traffic_capture: v })
.then(apply)
.catch(() => setTrafficCapture(!v)) // revert on failure
.finally(() => setSaving(false));
};
// Persist a web-search patch (enable and/or backend). Optimistic with refetch.
const saveWebSearch = (patch: Partial<Settings>) => {
setSaving(true);
api
.setSettings(patch)
.then((s) => {
apply(s);
toast.success("已保存网络搜索配置");
})
.catch((e) => {
toast.error("保存失败:" + (e as Error).message);
api
.settings()
.then(apply)
.catch(() => undefined);
})
.finally(() => setSaving(false));
};
const saveBraveKey = () => {
setSavingKey(true);
api
.setSettings({ brave_search_api_key: braveKeyInput })
.then((s) => {
apply(s);
setBraveKeyInput("");
toast.success("已保存 Brave API Key");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingKey(false));
};
const saveTavilyKey = () => {
setSavingTavilyKey(true);
api
.setSettings({ tavily_search_api_key: tavilyKeyInput })
.then((s) => {
apply(s);
setTavilyKeyInput("");
toast.success("已保存 Tavily API Key");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingTavilyKey(false));
};
const saveProxy = () => {
setSavingProxy(true);
api
.setSettings({ web_search_proxy: proxyInput.trim() })
.then((s) => {
apply(s);
toast.success(proxyInput.trim() ? "已保存出口代理" : "已清除出口代理(改为直连)");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingProxy(false));
};
// Run a real "test" search ("test") against the CURRENT form values (backend +
// proxy + entered key), falling back to saved values server-side. Toasts result.
const runTest = () => {
setTesting(true);
api
.testWebSearch({
web_search_backend: backend,
web_search_proxy: proxyInput.trim(),
brave_search_api_key: braveKeyInput,
tavily_search_api_key: tavilyKeyInput,
})
.then((r) => {
if (r.ok) toast.success(`搜索测试成功 · ${r.backend} 返回 ${r.count} 条结果`);
else toast.error("搜索测试失败:" + (r.error || "未知错误"));
})
.catch((e) => toast.error("搜索测试失败:" + (e as Error).message))
.finally(() => setTesting(false));
};
// brave-free selected but no key stored and none being entered → tool stays off.
const braveNeedsKey = webSearch && backend === "brave-free" && !braveKeySet;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">系统配置</h1>
<p className="text-muted-foreground text-sm">全局运行时开关</p>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
流量捕获
</CardTitle>
<CardDescription>
开启后,所有 Agent 的 HTTP 流量经记录代理全量落库,并向 Agent 注入 traffic_search / traffic_get
工具与代理配置(提示词含代理说明)。
<br />
关闭(默认)时不记录任何流量:Agent
<b>不会</b>拿到代理配置与流量工具,提示词也<b>不含</b>代理相关内容。切换后会即时重建 Agent 生效。
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
{trafficCapture ? "已开启 · 正在记录流量并注入代理" : "已关闭 · 不记录、不注入代理"}
</Label>
<Switch
id="traffic-capture"
checked={trafficCapture}
disabled={!loaded || saving}
onCheckedChange={toggleTraffic}
/>
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<SearchIcon className="size-4" />
网络搜索
</CardTitle>
<CardDescription>
这是网络搜索的<b>总开关 + 来源配置</b>。开启后,才能在<b>每个 Agent 的配置</b>里单独选择是否启用
<b>web_search</b>(仅返回标题/链接/摘要,不抓取正文;抓取由 WebFetch 负责)。网络搜索<b>不走</b>记录代理,独立于流量捕获。
<br />
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)或 <b>Tavily</b>(需填写 Tavily API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
{webSearch ? "总开关已开启 · 可在各 Agent 配置里单独启用" : "已关闭 · 各 Agent 无法启用网络搜索"}
</Label>
<Switch
id="web-search"
checked={webSearch}
disabled={!loaded || saving}
onCheckedChange={(v) => {
setWebSearch(v); // optimistic
saveWebSearch({ web_search_enabled: v });
}}
/>
</div>
{webSearch && (
<div className="flex items-center justify-between gap-4">
<Label className="text-sm font-normal text-muted-foreground">搜索来源</Label>
<Select
value={backend}
disabled={!loaded || saving}
onValueChange={(v) => {
setBackend(v); // optimistic
saveWebSearch({ web_search_backend: v });
}}
>
<SelectTrigger className="w-48 shrink-0">
<SelectValue placeholder="选择来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ddgs">DuckDuckGo(ddgs · 免费无 Key)</SelectItem>
<SelectItem value="brave-free">Brave(免费版 · 需 Key)</SelectItem>
<SelectItem value="tavily">Tavily(需 Key)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{webSearch && backend === "brave-free" && (
<div className="flex flex-col gap-2">
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
Brave Search API Key
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="brave-key"
type="password"
autoComplete="off"
placeholder={braveKeySet ? "已配置(留空则不变)" : "输入 Brave API Key"}
value={braveKeyInput}
disabled={!loaded || savingKey}
onChange={(e) => setBraveKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveBraveKey}
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
>
保存
</Button>
</div>
{braveNeedsKey && (
<p className="text-xs text-amber-500">
已选择 Brave 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">
免费版额度约 2,000 次/月。前往 https://brave.com/search/api/ 获取 Key。
</p>
</div>
)}
{webSearch && backend === "tavily" && (
<div className="flex flex-col gap-2">
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
Tavily Search API Key
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="tavily-key"
type="password"
autoComplete="off"
placeholder={tavilyKeySet ? "已配置(留空则不变)" : "输入 Tavily API Key(tvly-…)"}
value={tavilyKeyInput}
disabled={!loaded || savingTavilyKey}
onChange={(e) => setTavilyKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveTavilyKey}
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
>
保存
</Button>
</div>
{webSearch && backend === "tavily" && !tavilyKeySet && (
<p className="text-xs text-amber-500">
已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">
前往 https://tavily.com 注册并获取 API Key。
</p>
</div>
)}
{webSearch && (
<div className="flex flex-col gap-2">
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
出口代理(可选)
</Label>
<div className="flex items-center gap-2">
<Input
id="ws-proxy"
autoComplete="off"
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
value={proxyInput}
disabled={!loaded || savingProxy}
onChange={(e) => setProxyInput(e.target.value)}
/>
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
保存
</Button>
</div>
<p className="text-muted-foreground text-xs">
独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
</p>
</div>
)}
{webSearch && (
<div className="flex items-center justify-between gap-4 border-t pt-4">
<p className="text-muted-foreground text-xs">
用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
</p>
<Button type="button" variant="outline" onClick={runTest} disabled={!loaded || testing} className="shrink-0">
{testing ? "测试中…" : "测试搜索"}
</Button>
</div>
)}
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
自定义脚本 · Python 解释器
</CardTitle>
<CardDescription>
自定义 <b>script</b> 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv /
特定版本的绝对路径,留空则运行时自动检测。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
className="font-mono text-sm"
placeholder="/usr/bin/python3(留空=自动检测)"
value={pyInterp}
disabled={!loaded || saving}
onChange={(e) => setPyInterp(e.target.value)}
/>
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
重新检测
</Button>
<Button onClick={savePython} disabled={!loaded || saving}>
保存
</Button>
</div>
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CpuIcon className="size-4" />
工作并发 · Work Agent 数
</CardTitle>
<CardDescription>
每个任务并发运行的工作 agent 数量(默认 3)。数值越大并发探测越多、消耗也越高。修改后<b>对之后启动的任务生效</b>,正在运行的任务不受影响。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
className="w-32 font-mono text-sm"
placeholder="3"
value={workers}
disabled={!loaded || savingWorkers}
onChange={(e) => setWorkers(e.target.value)}
/>
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
保存
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+764
View File
@@ -0,0 +1,764 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
ChevronRightIcon,
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
FileTextIcon,
FilePlusIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Agent, SkillItem, MCPServer } from "@/lib/types";
// ── Tree node ──────────────────────────────────────────────────────────────
interface TreeNode {
name: string;
path: string; // relative to skill root, dirs WITHOUT trailing slash
type: "file" | "dir";
children: TreeNode[];
}
// Backend returns dirs as "scripts/" (trailing slash) and files as "scripts/a.py".
function buildTree(entries: string[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
function ensureDir(dirPath: string, parentNodes: TreeNode[]): TreeNode[] {
if (dirMap.has(dirPath)) return dirMap.get(dirPath)!.children;
const parts = dirPath.split("/");
let nodes = root;
let cur = "";
for (const part of parts) {
cur = cur ? `${cur}/${part}` : part;
if (!dirMap.has(cur)) {
const d: TreeNode = { name: part, path: cur, type: "dir", children: [] };
nodes.push(d);
dirMap.set(cur, d);
}
nodes = dirMap.get(cur)!.children;
}
return nodes;
}
for (const entry of [...entries].sort()) {
if (entry.endsWith("/")) {
// Explicit directory entry — ensure the node exists (may already be created)
ensureDir(entry.slice(0, -1), root);
} else {
// File — ensure parent dirs exist, then push file node
const parts = entry.split("/");
let nodes = root;
let cur = "";
for (let i = 0; i < parts.length - 1; i++) {
cur = cur ? `${cur}/${parts[i]}` : parts[i];
if (!dirMap.has(cur)) {
const d: TreeNode = { name: parts[i], path: cur, type: "dir", children: [] };
nodes.push(d);
dirMap.set(cur, d);
}
nodes = dirMap.get(cur)!.children;
}
const fname = parts[parts.length - 1];
if (fname) nodes.push({ name: fname, path: entry, type: "file", children: [] });
}
}
return root;
}
// ── State types ───────────────────────────────────────────────────────────
type Selected =
| { skill: string; path: null }
| { skill: string; path: string };
type Creating = {
skill: string;
inDir: string;
kind: "file" | "dir";
} | null;
// ── Page ──────────────────────────────────────────────────────────────────
export default function SkillsPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [selected, setSelected] = React.useState<Selected | null>(null);
const [visibility, setVisibility] = React.useState<Record<string, string[]>>({});
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
// file editor
const [fileContent, setFileContent] = React.useState("");
const [fileLoading, setFileLoading] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [saving, setSaving] = React.useState(false);
// inline create — using a ref to avoid stale closure in onBlur
const [creating, setCreating] = React.useState<Creating>(null);
const [newEntryName, setNewEntryName] = React.useState("");
const inlineRef = React.useRef<HTMLInputElement>(null);
// cancelRef prevents onBlur from committing when Escape was pressed
const cancelRef = React.useRef(false);
// new skill dialog
const [newOpen, setNewOpen] = React.useState(false);
const [newName, setNewName] = React.useState("");
const [newDesc, setNewDesc] = React.useState("");
const [newLicense, setNewLicense] = React.useState("");
const [newCompat, setNewCompat] = React.useState("");
const [newInst, setNewInst] = React.useState("");
const [newMcps, setNewMcps] = React.useState<string[]>([]);
const [newVisibility, setNewVisibility] = React.useState<string[]>([]);
const [mcpOptions, setMcpOptions] = React.useState<MCPServer[]>([]);
const [creatingSkill, setCreatingSkill] = React.useState(false);
const [uploading, setUploading] = React.useState(false);
const uploadRef = React.useRef<HTMLInputElement>(null);
// skill detail — MCP edit state (optimistic, rolls back on error)
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
// ── Data ──────────────────────────────────────────────────────────────────
const load = React.useCallback(() => {
api.agents().then(setAgents).catch(() => {});
api.mcpServers().then(setMcpOptions).catch(() => {});
api.skills().then((ss) => {
setSkills(ss);
ss.forEach((s) =>
api.skillVisibility(s.name)
.then((ids) => setVisibility((v) => ({ ...v, [s.name]: ids })))
.catch(() => {}),
);
}).catch(() => {});
}, []);
React.useEffect(() => { load(); }, [load]);
// ── Upload a .zip skill ───────────────────────────────────────────────────
async function uploadZip(file: File, overwrite = false) {
setUploading(true);
try {
const r = await api.uploadSkill(file, overwrite);
toast.success(`已安装 Skill:${r.name}(${r.files} 个文件)`);
load();
} catch (e) {
const msg = (e as Error).message;
// offer overwrite when the skill already exists
if (!overwrite && msg.includes("已存在")) {
if (window.confirm(`${msg}\n\n是否覆盖同名 Skill?`)) {
await uploadZip(file, true);
return;
}
} else {
toast.error("上传失败:" + msg);
}
} finally {
setUploading(false);
}
}
function onUploadPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
e.target.value = ""; // reset so picking the same file again re-fires
if (f) uploadZip(f);
}
React.useEffect(() => {
if (creating) {
// Small timeout so the element is actually in the DOM before focusing
setTimeout(() => inlineRef.current?.focus(), 20);
}
}, [creating]);
// Sync detail-panel MCP state when the selected skill changes.
React.useEffect(() => {
if (!selected || selected.path !== null) return;
const sk = skills.find((s) => s.name === selected.skill);
setDetailMcps(sk?.mcps ?? []);
}, [selected, skills]);
React.useEffect(() => {
if (!selected || selected.path === null) { setFileContent(""); setDirty(false); return; }
setFileLoading(true);
api.readSkillFile(selected.skill, selected.path)
.then((c) => { setFileContent(c); setDirty(false); })
.catch(() => toast.error("读取文件失败"))
.finally(() => setFileLoading(false));
}, [selected]);
// ── Expand helpers ────────────────────────────────────────────────────────
function toggleExpanded(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
function ensureExpanded(skill: string, dirPath: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.add(skill);
if (dirPath) {
let cur = "";
for (const part of dirPath.split("/")) {
cur = cur ? `${cur}/${part}` : part;
next.add(`${skill}:${cur}`);
}
}
return next;
});
}
// ── Inline create ─────────────────────────────────────────────────────────
function startCreate(skill: string, inDir: string, kind: "file" | "dir") {
cancelRef.current = false;
ensureExpanded(skill, inDir);
setCreating({ skill, inDir, kind });
setNewEntryName("");
}
// Use a ref snapshot so commitCreate reads the latest creating value
// without depending on potentially stale closure state.
const creatingRef = React.useRef<Creating>(null);
React.useEffect(() => { creatingRef.current = creating; }, [creating]);
const newEntryRef = React.useRef("");
React.useEffect(() => { newEntryRef.current = newEntryName; }, [newEntryName]);
async function commitCreate() {
if (cancelRef.current) { cancelRef.current = false; return; }
const c = creatingRef.current;
const name = newEntryRef.current.trim();
setCreating(null);
setNewEntryName("");
if (!c || !name) return;
const fullPath = c.inDir ? `${c.inDir}/${name}` : name;
try {
if (c.kind === "dir") {
await api.createSkillDir(c.skill, fullPath);
toast.success(`已创建文件夹:${fullPath}`);
ensureExpanded(c.skill, fullPath);
} else {
await api.writeSkillFile(c.skill, fullPath, "");
toast.success(`已创建文件:${fullPath}`);
setSelected({ skill: c.skill, path: fullPath });
ensureExpanded(c.skill, c.inDir);
}
load();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
}
}
function cancelCreate() {
cancelRef.current = true;
setCreating(null);
setNewEntryName("");
}
async function deletePath(skill: string, path: string) {
try {
await api.deleteSkillPath(skill, path);
toast.success(`已删除:${path}`);
if (selected?.skill === skill && selected.path === path) setSelected(null);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function deleteSkill(name: string) {
try {
await api.deleteSkill(name);
toast.success(`已删除 Skill:${name}`);
if (selected?.skill === name) setSelected(null);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function saveFile() {
if (!selected || selected.path === null) return;
setSaving(true);
try {
await api.writeSkillFile(selected.skill, selected.path, fileContent);
toast.success("已保存");
setDirty(false);
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally { setSaving(false); }
}
async function toggleSkillMcp(skillName: string, mcpName: string, mcpOn: boolean) {
const next = mcpOn
? [...detailMcps, mcpName]
: detailMcps.filter((n) => n !== mcpName);
setDetailMcps(next);
try {
await api.updateSkillMeta(skillName, { mcps: next });
toast.success(`${mcpOn ? "关联" : "取消关联"}「${mcpName}」`);
load();
} catch (e) {
// roll back on error
setDetailMcps(detailMcps);
toast.error("操作失败:" + (e as Error).message);
}
}
async function toggleVisibility(skillName: string, agentId: string, agentName: string) {
const on = (visibility[skillName] ?? []).includes(agentId);
try {
await api.toggleSkillVisibility(agentId, skillName, !on);
toast.success(`${on ? "取消" : "授予"}「${agentName}」可见`);
const ids = await api.skillVisibility(skillName);
setVisibility((v) => ({ ...v, [skillName]: ids }));
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
async function createNewSkill() {
if (!newName.trim()) { toast.error("请填写 name"); return; }
if (!newDesc.trim()) { toast.error("description 为必填项"); return; }
setCreatingSkill(true);
try {
const name = newName.trim();
await api.createSkill({
name, description: newDesc.trim(),
license: newLicense.trim() || undefined,
compatibility: newCompat.trim() || undefined,
mcps: newMcps.length ? newMcps : undefined,
instructions: newInst.trim() || undefined,
});
// apply initial visibility (fire-and-forget per agent; best-effort)
await Promise.all(newVisibility.map((id) => api.toggleSkillVisibility(id, name, true)));
toast.success("已创建 Skill");
setNewOpen(false);
setNewName(""); setNewDesc(""); setNewLicense(""); setNewCompat(""); setNewInst("");
setNewMcps([]); setNewVisibility([]);
load();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
} finally { setCreatingSkill(false); }
}
// ── Inline input JSX helper (NOT a React component — avoids remount on re-render) ──
// Defined as a plain function returning JSX so React never sees a new component type.
function inlineInputJSX(indent: number) {
return (
<div
key="__inline_create__"
className="flex items-center gap-1 py-0.5 pr-2"
style={{ paddingLeft: indent }}
>
{creating?.kind === "dir"
? <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
: <FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
}
<Input
ref={inlineRef}
className="h-6 flex-1 px-1 py-0 font-mono text-xs"
placeholder={creating?.kind === "dir" ? "folder-name" : "filename.py"}
value={newEntryName}
onChange={(e) => setNewEntryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { cancelRef.current = false; void commitCreate(); }
if (e.key === "Escape") cancelCreate();
}}
onBlur={() => { void commitCreate(); }}
/>
</div>
);
}
// ── Recursive tree renderer ───────────────────────────────────────────────
function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode {
const baseIndent = 8 + depth * 14;
return nodes.map((node) => {
if (node.type === "dir") {
const key = `${skill}:${node.path}`;
const open = expanded.has(key);
return (
<div key={node.path}>
<div
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
style={{ paddingLeft: baseIndent }}
onClick={() => toggleExpanded(key)}
>
<ChevronRightIcon className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
{open
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
}
<span className="flex-1 truncate">{node.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="新建文件夹"
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "dir"); }}>
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
</div>
{open && (
<>
{renderTree(node.children, skill, depth + 1)}
{creating?.skill === skill && creating.inDir === node.path &&
inlineInputJSX(baseIndent + 14)}
</>
)}
</div>
);
}
// file node
const isSelected = selected?.skill === skill && selected.path === node.path;
return (
<div
key={node.path}
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
style={{ paddingLeft: baseIndent + 16 }}
onClick={() => setSelected({ skill, path: node.path })}
>
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
title="删除文件"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</div>
);
});
}
const selectedSkill = selected
? skills.find((s) => s.name === selected.skill) ?? null
: null;
return (
<div data-content-padding="false" className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-col gap-0.5 border-b px-4 py-2.5 lg:px-6">
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
</div>
<div className="flex flex-1 overflow-hidden">
{/* ── 左侧文件树 ── */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="flex flex-col gap-2 border-b p-2">
<Button size="sm" variant="outline" className="w-full" onClick={() => setNewOpen(true)}>
<PlusIcon className="size-3.5" />新建 Skill
</Button>
<input
ref={uploadRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={onUploadPick}
/>
<Button
size="sm"
variant="outline"
className="w-full"
disabled={uploading}
onClick={() => uploadRef.current?.click()}
title="上传包含 SKILL.md 的 .zip 压缩包"
>
<UploadIcon className="size-3.5" />
{uploading ? "上传中…" : "上传压缩包"}
</Button>
</div>
<ScrollArea className="flex-1">
<div className="p-1">
{skills.map((s) => {
const isOpen = expanded.has(s.name);
const tree = buildTree(s.files);
const isSkillSelected = selected?.skill === s.name && selected.path === null;
return (
<div key={s.name}>
{/* skill 根节点 */}
<div
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => {
toggleExpanded(s.name);
setSelected({ skill: s.name, path: null });
}}
>
<ChevronRightIcon className={cn("size-3.5 shrink-0 transition-transform", isOpen && "rotate-90")} />
{isOpen
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
}
<span className="flex-1 truncate font-semibold">{s.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="新建文件夹"
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "dir"); }}>
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
</div>
{/* 展开:递归文件树 */}
{isOpen && (
<>
{renderTree(tree, s.name, 0)}
{creating?.skill === s.name && creating.inDir === "" &&
inlineInputJSX(22)}
</>
)}
</div>
);
})}
{skills.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">暂无 Skill,点击「新建」开始</p>
)}
</div>
</ScrollArea>
</div>
{/* ── 右侧面板 ── */}
<div className="flex flex-1 flex-col overflow-auto p-4">
{!selected && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">选择左侧 Skill 或文件</p>
</div>
)}
{selected && selected.path === null && selectedSkill && (
<div className="max-w-xl space-y-5">
<div>
<h2 className="font-mono text-base font-semibold">{selectedSkill.name}</h2>
{selectedSkill.description && (
<p className="mt-1 text-sm text-muted-foreground">{selectedSkill.description}</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{selectedSkill.license && (
<Badge variant="outline" className="text-xs font-normal">License: {selectedSkill.license}</Badge>
)}
{selectedSkill.compatibility && (
<Badge variant="secondary" className="text-xs font-normal">{selectedSkill.compatibility}</Badge>
)}
</div>
</div>
{/* ── 关联 MCP ── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
关联 MCP
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
</Label>
{mcpOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
) : (
<div className="flex flex-wrap gap-x-4 gap-y-2">
{mcpOptions.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={detailMcps.includes(m.name)}
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
/>
{m.name}
</label>
))}
</div>
)}
</div>
{/* ── 可见性 ── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
<div className="space-y-2">
{agents.map((a) => (
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
/>
{a.name}
</label>
))}
{agents.length === 0 && (
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
)}
</div>
</div>
</div>
)}
{selected && selected.path !== null && (
<div className="flex h-full flex-col gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{selected.skill}</span>
<span>/</span>
<span className="font-mono">{selected.path}</span>
<Button size="sm" className="ml-auto" onClick={saveFile} disabled={!dirty || saving}>
{saving ? "保存中…" : "保存"}
</Button>
</div>
{fileLoading ? (
<p className="text-xs text-muted-foreground">加载中…</p>
) : (
<Textarea
className="flex-1 resize-none font-mono text-xs"
value={fileContent}
onChange={(e) => { setFileContent(e.target.value); setDirty(true); }}
/>
)}
</div>
)}
</div>
</div>
{/* ── 新建 Skill 对话框 ── */}
<Sheet open={newOpen} onOpenChange={setNewOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">
<SheetHeader className="px-4 pt-4">
<SheetTitle>新建 Skill</SheetTitle>
</SheetHeader>
<Tabs defaultValue="basic" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-3 shrink-0 justify-start">
<TabsTrigger value="basic">基本信息</TabsTrigger>
<TabsTrigger value="mcp">
关联 MCP
{newMcps.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-primary-foreground">
{newMcps.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="visibility">
可见性
{newVisibility.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-primary-foreground">
{newVisibility.length}
</span>
)}
</TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="basic" className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 pt-4 data-[state=inactive]:hidden">
<div className="grid gap-1.5">
<Label htmlFor="sk-name">名称 <span className="text-destructive">*</span></Label>
<Input id="sk-name" placeholder="sqli-deepdive" value={newName} onChange={(e) => setNewName(e.target.value)} />
<p className="text-muted-foreground text-xs">小写字母 / 数字 / 连字符,1–64 字符</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sk-desc">描述 <span className="text-destructive">*</span></Label>
<Textarea id="sk-desc" rows={2} className="resize-none"
placeholder="这个 skill 做什么、何时使用。"
value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">license</Label>
<Input placeholder="MIT / Proprietary" value={newLicense} onChange={(e) => setNewLicense(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">compatibility</Label>
<Input placeholder="需要 sqlmap、python3" value={newCompat} onChange={(e) => setNewCompat(e.target.value)} />
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
<Label htmlFor="sk-inst">正文 <span className="text-muted-foreground text-xs font-normal">(留空自动生成骨架)</span></Label>
<Textarea id="sk-inst"
className="min-h-40 flex-1 resize-none font-mono text-sm leading-relaxed"
placeholder={"## 执行方法\n\n1. 先探测错误\n2. 区分盲注类型\n\n脚本放 scripts/ 目录。"}
value={newInst} onChange={(e) => setNewInst(e.target.value)} />
</div>
</TabsContent>
{/* 关联 MCP */}
<TabsContent value="mcp" className="overflow-y-auto px-4 pb-4 pt-4 data-[state=inactive]:hidden">
<p className="mb-3 text-xs text-muted-foreground">加载 Skill 时才披露并解锁所选 MCP 的工具。</p>
{mcpOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
) : (
<div className="flex flex-col gap-2">
{mcpOptions.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={newMcps.includes(m.name)}
onCheckedChange={(on) =>
setNewMcps((cur) => on ? [...cur, m.name] : cur.filter((n) => n !== m.name))
}
/>
{m.name}
</label>
))}
</div>
)}
</TabsContent>
{/* 可见性 */}
<TabsContent value="visibility" className="overflow-y-auto px-4 pb-4 pt-4 data-[state=inactive]:hidden">
<p className="mb-3 text-xs text-muted-foreground">选中的 Agent 创建后即可见此 Skill。</p>
{agents.length === 0 ? (
<p className="text-xs text-muted-foreground">(暂无 Agent)</p>
) : (
<div className="flex flex-col gap-2">
{agents.map((a) => (
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={newVisibility.includes(a.id)}
onCheckedChange={(on) =>
setNewVisibility((cur) => on ? [...cur, a.id] : cur.filter((id) => id !== a.id))
}
/>
{a.name}
</label>
))}
</div>
)}
</TabsContent>
</Tabs>
<SheetFooter className="flex-row justify-end gap-2 border-t px-4 py-3">
<Button variant="outline" onClick={() => setNewOpen(false)}>取消</Button>
<Button onClick={createNewSkill} disabled={creatingSkill}>{creatingSkill ? "创建中…" : "创建"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
+835
View File
@@ -0,0 +1,835 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { PlayIcon, PlusIcon, RotateCcwIcon, SaveIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { api } from "@/lib/api";
import type { Agent, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// paramRows flattens schema.properties into an ordered row list for editing.
// name/type/required are read-only (welded to the Go handler); description/default
// are editable. Non-scalar params (array/object) can't carry an editable default.
// parentKey is set for rows that live inside an array param's items.properties.
type ParamRow = {
name: string;
type: string;
required: boolean;
description: string;
defaultStr: string; // "" = no default declared
scalar: boolean;
parentKey?: string; // set when this is a sub-field of an array param's items
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toRows(schema: Record<string, any>): ParamRow[] {
const props = (schema?.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>((schema?.required as string[]) ?? []);
const rows: ParamRow[] = [];
for (const [name, p] of Object.entries(props)) {
const type = String(p?.type ?? "");
const hasDefault = p != null && "default" in p && (p as Record<string, unknown>).default != null;
rows.push({
name, type,
required: required.has(name),
description: String(p?.description ?? ""),
defaultStr: hasDefault ? String((p as Record<string, unknown>).default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(type),
});
// Expand items.properties for array params so sub-fields are editable.
if (type === "array") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemProps = (p as any)?.items?.properties as Record<string, Record<string, unknown>> | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemRequired = new Set<string>(((p as any)?.items?.required as string[]) ?? []);
if (itemProps) {
for (const [subName, subP] of Object.entries(itemProps)) {
const subType = String((subP as Record<string, unknown>)?.type ?? "");
const subHasDefault = subP != null && "default" in subP && subP.default != null;
rows.push({
name: subName, type: subType,
required: itemRequired.has(subName),
description: String(subP?.description ?? ""),
defaultStr: subHasDefault ? String(subP.default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(subType),
parentKey: name,
});
}
}
}
}
return rows;
}
// coerceDefault turns the edited default string back into a typed JSON value, or
// undefined to drop the "default" key entirely.
function coerceDefault(type: string, raw: string): unknown {
const s = raw.trim();
if (s === "") return undefined;
if (type === "integer" || type === "number") {
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
}
if (type === "boolean") return s === "true";
return raw;
}
// applyRows writes edited rows back into a deep-copied schema (structure untouched).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyRows(schema: Record<string, any>, rows: ParamRow[]): Record<string, any> {
const next = structuredClone(schema ?? {});
const props = (next.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const r of rows) {
if (r.parentKey) {
// Sub-field of an array param's items.properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent = props[r.parentKey] as any;
const subP = parent?.items?.properties?.[r.name] as Record<string, unknown> | undefined;
if (!subP) continue;
subP.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete subP.default;
else subP.default = dv;
}
} else {
const p = props[r.name];
if (!p) continue;
p.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete p.default;
else p.default = dv;
}
}
}
return next;
}
// ToolEditor is the full edit form for one tool, rendered inside the drawer.
function ToolEditor({
tool,
agents,
captureOn,
onSaved,
onClose,
}: {
tool: Tool;
agents: Agent[];
captureOn: boolean;
onSaved: () => void;
onClose: () => void;
}) {
// traffic tools can't be bound/enabled until the global 流量捕获 switch is on.
const trafficGated = TRAFFIC_TOOL_KEYS.has(tool.key) && !captureOn;
const [description, setDescription] = React.useState(tool.description);
const [bound, setBound] = React.useState<string[]>(tool.agents);
const [enabled, setEnabled] = React.useState(tool.enabled);
const [rows, setRows] = React.useState<ParamRow[]>(() => toRows(tool.schema));
const [saving, setSaving] = React.useState(false);
const setRow = (i: number, patch: Partial<ParamRow>) =>
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const toggleAgent = (k: string) =>
setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
async function save() {
setSaving(true);
try {
await api.saveTool(tool.key, {
description,
schema: applyRows(tool.schema, rows),
agents: bound,
enabled,
});
toast.success(`已保存工具「${tool.key}」`);
onSaved();
onClose();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function reset() {
try {
await api.resetTool(tool.key);
toast.success(`已恢复「${tool.key}」为代码默认`);
onSaved();
onClose();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
{trafficGated && (
<div className="border-amber-500/40 bg-amber-500/10 text-muted-foreground rounded-md border px-3 py-2 text-xs">
该工具依赖<b>流量捕获</b>。请先在「系统配置」开启流量捕获,才能绑定给 Agent 并启用。
</div>
)}
{/* binding + switch */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">绑定 Agent(决定该工具给哪些 Agent)</Label>
<div className="flex flex-wrap gap-3">
{agents.map((ag) => (
<label key={ag.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={bound.includes(ag.key)}
disabled={trafficGated}
onCheckedChange={() => toggleAgent(ag.key)}
/>
{ag.name}
<span className="text-muted-foreground font-mono text-xs">{ag.key}</span>
</label>
))}
{agents.length === 0 && <span className="text-muted-foreground text-xs">(无 Agent)</span>}
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} id={`en-${tool.key}`} />
<Label htmlFor={`en-${tool.key}`} className="text-sm">
启用
</Label>
</div>
</div>
{/* description */}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">工具描述(发送给模型)</Label>
<Textarea
className="font-mono text-xs"
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* params */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">
参数(名称 / 类型 / 必填只读;描述与默认值可改)
</Label>
{rows.length === 0 && <span className="text-muted-foreground text-xs">(无参数)</span>}
{rows.map((r, i) => (
<div
key={(r.parentKey ?? "") + "." + r.name}
className={r.parentKey
? "border-l-2 border-muted ml-3 pl-3 grid gap-2 py-2"
: "grid gap-2 rounded-md border p-3"
}
>
<div className="flex flex-wrap items-center gap-2">
{r.parentKey && <span className="text-muted-foreground font-mono text-[10px]">↳</span>}
<span className="font-mono text-sm">{r.name}</span>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{r.type || "?"}
</Badge>
{r.required && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
必填
</Badge>
)}
{r.parentKey && (
<span className="text-muted-foreground text-[10px]">items 子字段</span>
)}
</div>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">描述</Label>
<Input
className="text-xs"
value={r.description}
onChange={(e) => setRow(i, { description: e.target.value })}
/>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">默认值</Label>
<Input
className="text-xs"
placeholder={r.scalar ? "(空=无默认)" : "仅标量支持"}
disabled={!r.scalar}
value={r.defaultStr}
onChange={(e) => setRow(i, { defaultStr: e.target.value })}
/>
</div>
</div>
</div>
))}
</div>
</div>
<Separator className="mt-4" />
<div className="flex flex-wrap gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> 保存
</Button>
<Button size="sm" variant="outline" onClick={reset}>
<RotateCcwIcon /> 恢复默认
</Button>
</div>
</div>
);
}
// ToolGridCard is one clickable tile in the catalog grid.
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
return (
<button
onClick={onClick}
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium">{tool.key}</span>
{tool.system ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
系统
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义·{tool.kind}
</Badge>
)}
{tool.deferred && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
deferred
</Badge>
)}
{!tool.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
已停用
</Badge>
)}
</div>
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
{tool.description || "(无描述)"}
</p>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{tool.agents.length === 0 && (
<span className="text-muted-foreground text-[10px]">(未绑定 Agent)</span>
)}
{tool.agents.map((a) => (
<Badge key={a} variant="outline" className="px-1.5 py-0 text-[10px]">
{a}
</Badge>
))}
</div>
</button>
);
}
export default function ToolsPage() {
const [tools, setTools] = React.useState<Tool[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [captureOn, setCaptureOn] = React.useState(false);
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const [customEdit, setCustomEdit] = React.useState<Tool | "new" | null>(null);
const reload = React.useCallback(() => {
api.tools().then(setTools).catch(() => setTools([]));
}, []);
React.useEffect(() => {
reload();
api.agents().then(setAgents).catch(() => {});
api.settings().then((s) => setCaptureOn(!!s.traffic_capture)).catch(() => {});
}, [reload]);
const [query, setQuery] = React.useState("");
const selected = tools.find((t) => t.key === selectedKey) ?? null;
const matchTool = React.useCallback((t: Tool) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
t.key.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.agents.some((a) => a.toLowerCase().includes(q))
);
}, [query]);
const systemTools = tools.filter((t) => t.system && matchTool(t));
const customTools = tools.filter((t) => !t.system && matchTool(t));
const allSystemCount = tools.filter((t) => t.system).length;
const allCustomCount = tools.filter((t) => !t.system).length;
// clicking a built-in tool opens the binding drawer; a custom tool opens its full editor.
const openTool = (t: Tool) => (t.system ? setSelectedKey(t.key) : setCustomEdit(t));
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">工具</h1>
<p className="text-muted-foreground text-sm">系统工具的描述/绑定,以及自定义工具(command/script/http)</p>
</div>
<div className="relative w-64">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
className="h-8 pl-8 text-sm"
placeholder="搜索工具名、描述、Agent…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<Tabs defaultValue="system">
<TabsList>
<TabsTrigger value="system">系统工具</TabsTrigger>
<TabsTrigger value="custom">自定义工具</TabsTrigger>
</TabsList>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>系统工具</CardTitle>
<CardDescription>
{query.trim()
? `${systemTools.length} / ${allSystemCount} 个匹配,点击卡片编辑描述、参数默认值与 Agent 绑定`
: `共 ${allSystemCount} 个,点击卡片编辑描述、参数默认值与 Agent 绑定`}
</CardDescription>
</CardHeader>
<CardContent>
{systemTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? "没有匹配的系统工具" : "(暂无系统工具,等待后端 seed)"}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{systemTools.map((t) => (
<ToolGridCard key={t.key} tool={t} onClick={() => openTool(t)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="custom">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>自定义工具</CardTitle>
<CardDescription>
{query.trim()
? `shell/command/script/http,${customTools.length} / ${allCustomCount} 个匹配,点击卡片编辑`
: `shell(bash 声明) / command(命令) / script(Python) / http(API),共 ${allCustomCount} 个,点击卡片编辑`}
</CardDescription>
</div>
<Button size="sm" onClick={() => setCustomEdit("new")}>
<PlusIcon /> 新建自定义工具
</Button>
</div>
</CardHeader>
<CardContent>
{customTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? "没有匹配的自定义工具" : "(暂无自定义工具,点击右上角「新建自定义工具」)"}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customTools.map((t) => (
<ToolGridCard key={t.key} tool={t} onClick={() => openTool(t)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Sheet open={!!selected} onOpenChange={(o) => !o && setSelectedKey(null)}>
<SheetContent
side="right"
className="gap-0 p-0 data-[side=right]:w-[30vw] data-[side=right]:sm:max-w-[30vw]"
>
{selected && (
<>
<SheetHeader className="px-4">
<SheetTitle className="font-mono">{selected.key}</SheetTitle>
<SheetDescription>编辑描述、参数默认值与 Agent 绑定</SheetDescription>
</SheetHeader>
<ToolEditor
key={selected.key}
tool={selected}
agents={agents}
captureOn={captureOn}
onSaved={reload}
onClose={() => setSelectedKey(null)}
/>
</>
)}
</SheetContent>
</Sheet>
<CustomToolDialog
edit={customEdit}
agents={agents}
onClose={() => setCustomEdit(null)}
onSaved={() => {
setCustomEdit(null);
reload();
}}
/>
</div>
);
}
// ---- 自定义工具编辑器 ----
type ExecState = {
command: string;
code: string;
method: string;
url: string;
headers: string;
body: string;
timeout_ms: string;
proxy: string;
use_recording_proxy: boolean;
};
function CustomToolDialog({
edit,
agents,
onClose,
onSaved,
}: {
edit: Tool | "new" | null;
agents: Agent[];
onClose: () => void;
onSaved: () => void;
}) {
const isNew = edit === "new";
const tool = edit && edit !== "new" ? edit : null;
const [key, setKey] = React.useState("");
const [description, setDescription] = React.useState("");
const [kind, setKind] = React.useState<"shell" | "command" | "script" | "http">("shell");
const [ex, setEx] = React.useState<ExecState>({
command: "", code: "", method: "GET", url: "", headers: "", body: "", timeout_ms: "", proxy: "", use_recording_proxy: false,
});
const [schemaText, setSchemaText] = React.useState("");
const [bound, setBound] = React.useState<string[]>([]);
const [deferred, setDeferred] = React.useState(false);
const [enabled, setEnabled] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [paramsText, setParamsText] = React.useState("");
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<{ output: string; is_error: boolean } | null>(null);
// (re)load form state when opening.
React.useEffect(() => {
if (!edit) return;
setParamsText(""); setTestResult(null);
if (edit === "new") {
setKey(""); setDescription(""); setKind("shell");
setEx({ command: "", code: "", method: "GET", url: "", headers: "", body: "", timeout_ms: "", proxy: "", use_recording_proxy: false });
setSchemaText(""); setBound([]); setDeferred(false); setEnabled(true);
return;
}
const t = edit;
const e = (t.exec ?? {}) as Record<string, unknown>;
setKey(t.key);
setDescription(t.description);
setKind((t.kind as "shell" | "command" | "script" | "http") ?? "shell");
setEx({
command: String(e.command ?? ""),
code: String(e.code ?? ""),
method: String(e.method ?? "GET"),
url: String(e.url ?? ""),
headers: e.headers ? JSON.stringify(e.headers, null, 2) : "",
body: String(e.body ?? ""),
timeout_ms: e.timeout_ms ? String(e.timeout_ms) : "",
proxy: String(e.proxy ?? ""),
use_recording_proxy: !!e.use_recording_proxy,
});
setSchemaText(t.schema && Object.keys(t.schema).length ? JSON.stringify(t.schema, null, 2) : "");
setBound(t.agents ?? []);
setDeferred(!!t.deferred);
setEnabled(t.enabled);
}, [edit]);
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
function buildExec(): Record<string, unknown> {
if (kind === "shell") return {};
const t = ex.timeout_ms ? Number(ex.timeout_ms) : undefined;
if (kind === "command") return { command: ex.command, timeout_ms: t };
if (kind === "script") return { code: ex.code, timeout_ms: t };
let headers: Record<string, string> = {};
if (ex.headers.trim()) {
try { headers = JSON.parse(ex.headers); } catch { /* validated on save */ }
}
return { method: ex.method, url: ex.url, headers, body: ex.body, timeout_ms: t, proxy: ex.proxy, use_recording_proxy: ex.use_recording_proxy };
}
async function save() {
if (isNew && !/^[a-z][a-z0-9_]*$/.test(key.trim())) {
toast.error("key 需小写字母开头,仅含小写字母/数字/下划线");
return;
}
let schema: Record<string, unknown> = {};
if (schemaText.trim()) {
try { schema = JSON.parse(schemaText); } catch { toast.error("参数 JSON Schema 格式错误"); return; }
}
if (kind === "http" && ex.headers.trim()) {
try { JSON.parse(ex.headers); } catch { toast.error("headers JSON 格式错误"); return; }
}
if (kind === "http") {
const props = (schema as { properties?: Record<string, unknown> }).properties;
if (!props || Object.keys(props).length === 0) {
toast.error("http 工具必须提供参数 JSON Schema(含 properties,不能留空)");
return;
}
}
setSaving(true);
const payload = { description, schema: kind === "shell" ? {} : schema, agents: bound, enabled, kind, exec: buildExec(), deferred: kind === "shell" ? false : deferred };
try {
if (isNew) await api.createCustomTool({ key: key.trim(), ...payload });
else await api.updateCustomTool(tool!.key, payload);
toast.success(isNew ? "已创建自定义工具" : "已保存");
onSaved();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function del() {
if (!tool) return;
try {
await api.deleteCustomTool(tool.key);
toast.success("已删除");
onSaved();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
// runTest dry-runs the CURRENT form (unsaved) with the sample params, so a
// template/script/request can be debugged before saving.
async function runTest() {
let params: Record<string, unknown> = {};
if (paramsText.trim()) {
try { params = JSON.parse(paramsText); } catch { toast.error("测试参数 JSON 格式错误"); return; }
}
if (kind === "http" && ex.headers.trim()) {
try { JSON.parse(ex.headers); } catch { toast.error("headers JSON 格式错误"); return; }
}
setTesting(true);
setTestResult(null);
try {
const r = await api.testCustomTool({ kind, exec: buildExec(), params });
setTestResult(r);
} catch (e) {
setTestResult({ output: (e as Error).message, is_error: true });
} finally {
setTesting(false);
}
}
return (
<Sheet open={!!edit} onOpenChange={(o) => !o && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw] data-[side=right]:min-w-[480px]"
>
<SheetHeader className="px-4">
<SheetTitle>{isNew ? "新建自定义工具" : `编辑 ${tool?.key}`}</SheetTitle>
<SheetDescription>shell=bash 环境声明(只需名称+描述,告知模型可用 bash 调用);command/script/http 需写执行规格。</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="grid gap-1.5">
<Label className="text-xs">Key</Label>
<Input className="font-mono" placeholder="如 nmap_scan" value={key} disabled={!isNew}
onChange={(e) => setKey(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">描述(发给模型)</Label>
<Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">类型</Label>
<Select value={kind} onValueChange={(v) => setKind(v as "shell" | "command" | "script" | "http")}>
<SelectTrigger className="w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="shell">shell(bash 环境声明)</SelectItem>
<SelectItem value="command">command(shell 命令模板)</SelectItem>
<SelectItem value="script">script(Python 脚本)</SelectItem>
<SelectItem value="http">http(API 请求)</SelectItem>
</SelectContent>
</Select>
{kind === "shell" && (
<p className="text-muted-foreground text-xs">适合 nmap、sqlmap、ffuf 等出名工具——模型已知用法,只需声明"可在 bash 中调用"即可。名称+描述会追加到 Bash 工具描述里。</p>
)}
</div>
{kind === "command" && (
<div className="grid gap-1.5">
<Label className="text-xs">命令模板(占位符 {"{param}"},如 nmap -p {"{ports}"} {"{target}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.command}
onChange={(e) => setEx({ ...ex, command: e.target.value })} />
</div>
)}
{kind === "script" && (
<div className="grid gap-1.5">
<Label className="text-xs">Python 正文(参数走 stdin JSON / os.environ["TOOL_X"])</Label>
<Textarea className="font-mono text-xs" rows={10} value={ex.code}
placeholder={'import json,sys\nargs=json.load(sys.stdin)\nprint(...)'}
onChange={(e) => setEx({ ...ex, code: e.target.value })} />
</div>
)}
{kind === "http" && (
<div className="grid gap-2">
<div className="flex gap-2">
<div className="grid gap-1.5">
<Label className="text-xs">Method</Label>
<Input className="w-24" value={ex.method} onChange={(e) => setEx({ ...ex, method: e.target.value })} />
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-xs">URL(可含 {"{param}"})</Label>
<Input className="font-mono text-xs" value={ex.url} onChange={(e) => setEx({ ...ex, url: e.target.value })} />
</div>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Headers(JSON,可含 {"{param}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.headers}
placeholder={'{"Authorization": "Bearer {token}"}'} onChange={(e) => setEx({ ...ex, headers: e.target.value })} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Body(可含 {"{param}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.body} onChange={(e) => setEx({ ...ex, body: e.target.value })} />
</div>
<div className="flex items-center gap-4">
<div className="grid gap-1.5">
<Label className="text-xs">代理 URL(空=直连)</Label>
<Input className="font-mono text-xs w-56" value={ex.proxy} onChange={(e) => setEx({ ...ex, proxy: e.target.value })} />
</div>
<label className="mt-4 flex items-center gap-2 text-sm">
<Checkbox checked={ex.use_recording_proxy} onCheckedChange={(v) => setEx({ ...ex, use_recording_proxy: !!v })} />
走记录代理
</label>
</div>
</div>
)}
{kind !== "shell" && (
<div className="flex items-center gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">超时(ms,空=默认)</Label>
<Input type="number" className="w-32" value={ex.timeout_ms} onChange={(e) => setEx({ ...ex, timeout_ms: e.target.value })} />
</div>
</div>
)}
{kind !== "shell" && (
<div className="grid gap-1.5">
<Label className="text-xs">
参数 JSON Schema{kind === "http" ? "(http 工具必填,需含 properties)" : "(留空 = 自动给 {args} 薄壳)"}
</Label>
<Textarea className="font-mono text-xs" rows={4} value={schemaText}
placeholder={'{"type":"object","properties":{"target":{"type":"string"}},"required":["target"]}'}
onChange={(e) => setSchemaText(e.target.value)} />
</div>
)}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">绑定 Agent</Label>
<div className="flex flex-wrap gap-3">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox checked={bound.includes(a.key)} onCheckedChange={() => toggleAgent(a.key)} />
{a.name}<span className="text-muted-foreground font-mono text-xs">{a.key}</span>
</label>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} /> 启用
</label>
{kind !== "shell" && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={deferred} onCheckedChange={setDeferred} /> deferred(大量不常用工具才开)
</label>
)}
</div>
{kind !== "shell" && (
<div className="grid gap-1.5 rounded-md border p-3">
<Label className="text-xs font-medium">测试运行(用当前表单,不会保存)</Label>
<Textarea className="font-mono text-xs" rows={2} value={paramsText}
placeholder={'示例参数 JSON,如 {"target":"example.com"}'}
onChange={(e) => setParamsText(e.target.value)} />
<div>
<Button size="sm" variant="outline" onClick={runTest} disabled={testing}>
<PlayIcon /> {testing ? "运行中…" : "测试运行"}
</Button>
</div>
{testResult && (
<pre
className={
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs " +
(testResult.is_error ? "text-destructive" : "")
}
>
{testResult.output || "(无输出)"}
</pre>
)}
</div>
)}
</div>
<Separator />
<div className="flex items-center gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> {isNew ? "创建" : "保存"}
</Button>
{!isNew && (
<Button size="sm" variant="outline" className="text-destructive" onClick={del}>
<Trash2Icon /> 删除
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+284
View File
@@ -0,0 +1,284 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* Theme preset styles: these override CSS variables based on the selected data-theme-preset */
@import "../styles/presets/brutalist.css";
@import "../styles/presets/soft-pop.css";
@import "../styles/presets/tangerine.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
}
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
These serve as the fallback; there is no separate default.css file. */
:root {
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* fonts */
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans), system-ui, sans-serif;
}
html {
@apply font-sans;
}
}
@layer utilities {
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
box-shadow: var(--shadow-2xs);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-xs {
box-shadow: var(--shadow-xs);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-sm {
box-shadow: var(--shadow-sm);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow {
box-shadow: var(--shadow);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-md {
box-shadow: var(--shadow-md);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-lg {
box-shadow: var(--shadow-lg);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-xl {
box-shadow: var(--shadow-xl);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xl {
box-shadow: var(--shadow-2xl);
}
html[data-font="inter"] body {
--font-sans: var(--font-inter);
}
html[data-font="notoSans"] body {
--font-sans: var(--font-noto-sans);
}
html[data-font="nunitoSans"] body {
--font-sans: var(--font-nunito-sans);
}
html[data-font="figtree"] body {
--font-sans: var(--font-figtree);
}
html[data-font="roboto"] body {
--font-sans: var(--font-roboto);
}
html[data-font="geist"] body {
--font-sans: var(--font-geist);
}
html[data-font="raleway"] body {
--font-sans: var(--font-raleway);
}
html[data-font="dmSans"] body {
--font-sans: var(--font-dm-sans);
}
html[data-font="publicSans"] body {
--font-sans: var(--font-public-sans);
}
html[data-font="outfit"] body {
--font-sans: var(--font-outfit);
}
html[data-font="geistMono"] body {
--font-sans: var(--font-geist-mono);
}
html[data-font="geistPixelSquare"] body {
--font-sans: var(--font-geist-pixel-square);
}
html[data-font="jetBrainsMono"] body {
--font-sans: var(--font-jetbrains-mono);
}
html[data-font="notoSerif"] body {
--font-sans: var(--font-noto-serif);
}
html[data-font="robotoSlab"] body {
--font-sans: var(--font-roboto-slab);
}
html[data-font="merriweather"] body {
--font-sans: var(--font-merriweather);
}
html[data-font="lora"] body {
--font-sans: var(--font-lora);
}
html[data-font="playfairDisplay"] body {
--font-sans: var(--font-playfair-display);
}
}
html {
overscroll-behavior: none;
}
.disable-transitions * {
transition: none !important;
}
[data-print-root] {
display: none;
}
@media print {
@page {
size: Letter;
margin: 0;
}
html,
body {
width: 8.5in !important;
height: 11in !important;
margin: 0 !important;
overflow: hidden !important;
}
body > *:not([data-print-root]) {
display: none !important;
}
[data-print-root] {
display: block !important;
width: 8.5in !important;
height: 11in !important;
margin: 0 !important;
overflow: hidden !important;
background: white !important;
}
[data-print-root] [data-print-paper] {
width: 8.5in !important;
height: 11in !important;
margin: 0 !important;
box-shadow: none !important;
}
[data-print-root] [data-print-paper],
[data-print-root] [data-print-paper] * {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+55
View File
@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { APP_CONFIG } from "@/config/app-config";
import { fontVars } from "@/lib/fonts/registry";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { ThemeBootScript } from "@/scripts/theme-boot";
import { PreferencesStoreProvider } from "@/stores/preferences/preferences-provider";
import "./globals.css";
export const metadata: Metadata = {
title: APP_CONFIG.meta.title,
description: APP_CONFIG.meta.description,
};
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
const { theme_mode, theme_preset, content_layout, navbar_style, sidebar_variant, sidebar_collapsible, font } =
PREFERENCE_DEFAULTS;
return (
<html
lang="en"
data-theme-mode={theme_mode}
data-theme-preset={theme_preset}
data-content-layout={content_layout}
data-navbar-style={navbar_style}
data-sidebar-variant={sidebar_variant}
data-sidebar-collapsible={sidebar_collapsible}
data-font={font}
suppressHydrationWarning
>
<head>
{/* Applies theme and layout preferences on load to avoid flicker and unnecessary server rerenders. */}
<ThemeBootScript />
</head>
<body className={`${fontVars} min-h-screen antialiased`}>
<TooltipProvider>
<PreferencesStoreProvider
themeMode={theme_mode}
themePreset={theme_preset}
contentLayout={content_layout}
navbarStyle={navbar_style}
font={font}
>
{children}
<Toaster />
</PreferencesStoreProvider>
</TooltipProvider>
</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="flex h-dvh flex-col items-center justify-center space-y-2 text-center">
<h1 className="font-semibold text-2xl">Page not found.</h1>
<p className="text-muted-foreground">The page you are looking for could not be found.</p>
<Link prefetch={false} replace href="/function/tasks">
<Button variant="outline">Go back home</Button>
</Link>
</div>
);
}
+878
View File
@@ -0,0 +1,878 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { EyeIcon, GitCompareIcon, InfoIcon, RotateCcwIcon, SaveIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep this list in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// AgentEditor is the tabbed editor for one agent, used inside the agents-page
// drawer (and reused full-page for deep links). Tabs: 配置与提示词 / MCP / Skill /
// Tools. Config + prompt save as before; visibility + tool bindings toggle live.
export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?: () => void }) {
const [detail, setDetail] = React.useState<AgentDetail | null>(null);
const [versions, setVersions] = React.useState<PromptVersion[]>([]);
const [variables, setVariables] = React.useState<PromptVar[]>([]);
const [mcp, setMcp] = React.useState<MCPServer[]>([]);
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
const [loaded, setLoaded] = React.useState(false);
const [viewVer, setViewVer] = React.useState<PromptVersion | null>(null);
const [diffVer, setDiffVer] = React.useState<PromptVersion | null>(null);
const [prompt, setPrompt] = React.useState("");
const [mcpVisible, setMcpVisible] = React.useState<number[]>([]);
const [skillVisible, setSkillVisible] = React.useState<string[]>([]);
const [preview, setPreview] = React.useState("");
const [maxTurns, setMaxTurns] = React.useState("0");
const [runSecs, setRunSecs] = React.useState("600");
const [webSearch, setWebSearch] = React.useState(false);
const [interactiveShell, setInteractiveShell] = React.useState(false);
const [wrapup, setWrapup] = React.useState("");
const [wrapupDefault, setWrapupDefault] = React.useState("");
const [wrapupTurns, setWrapupTurns] = React.useState("0");
const [wrapupTurnsDefault, setWrapupTurnsDefault] = React.useState(5);
// 任务级超时收尾词(仅 worker/planner)
const [ttSupported, setTtSupported] = React.useState(false);
const [ttWrapup, setTtWrapup] = React.useState("");
const [ttWrapupDefault, setTtWrapupDefault] = React.useState("");
const [ttTurns, setTtTurns] = React.useState("0");
const [ttTurnsDefault, setTtTurnsDefault] = React.useState(5);
const [settings, setSettings] = React.useState<Settings | null>(null);
React.useEffect(() => {
api.mcpServers().then(setMcp).catch(() => {});
api.skills().then(setSkills).catch(() => {});
api.tools().then(setTools).catch(() => {});
api.settings().then(setSettings).catch(() => {});
}, []);
// global gates: traffic tools need 流量捕获, web search needs the master switch.
const captureOn = !!settings?.traffic_capture;
const webSearchGlobalOn = !!settings?.web_search_enabled;
const reload = React.useCallback(() => {
api
.getAgent(agentKey)
.then((d) => {
setDetail(d);
setPrompt(d.prompt ?? "");
setVariables(d.variables ?? []);
setVersions(d.versions ?? []);
setMcpVisible(d.visibility?.mcp ?? []);
setSkillVisible(d.visibility?.skill ?? []);
setMaxTurns(String(d.agent?.max_turns ?? 0));
setRunSecs(String(d.agent?.run_seconds ?? 600));
setWebSearch(!!d.agent?.web_search);
setInteractiveShell(!!d.agent?.interactive_shell);
setWrapup(d.wrapup_prompt ?? "");
setWrapupDefault(d.wrapup_default ?? "");
setWrapupTurns(String(d.wrapup_max_turns ?? 0));
setWrapupTurnsDefault(d.wrapup_max_turns_default ?? 5);
setTtSupported(!!d.task_timeout_wrapup_supported);
setTtWrapup(d.task_timeout_wrapup_prompt ?? "");
setTtWrapupDefault(d.task_timeout_wrapup_default ?? "");
setTtTurns(String(d.task_timeout_wrapup_max_turns ?? 0));
setTtTurnsDefault(d.task_timeout_wrapup_max_turns_default ?? 5);
})
.catch(() => setDetail(null))
.finally(() => setLoaded(true));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
async function doPreview() {
try {
const r = await api.previewAgentPrompt(agentKey, prompt);
setPreview(r.error ? "渲染错误:" + r.error : r.rendered);
} catch (e) {
setPreview("预览失败:" + (e as Error).message);
}
}
async function savePrompt() {
try {
const r = await api.saveAgentPrompt(agentKey, prompt);
toast.success(`已保存为版本 v${r.version}`);
reload();
onSaved?.();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetPrompt() {
try {
const r = await api.resetAgentPrompt(agentKey);
toast.success(`已恢复为内置默认(v${r.version})`);
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveWrapup() {
try {
const turns = Math.max(0, Math.floor(Number(wrapupTurns) || 0));
await api.saveAgentWrapup(agentKey, wrapup, turns);
toast.success(wrapup.trim() || turns > 0 ? "收尾配置已保存(下次运行生效)" : "已清空,将使用内置默认");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetWrapup() {
try {
await api.resetAgentWrapup(agentKey);
toast.success("已恢复为内置默认");
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveTaskTimeoutWrapup() {
try {
const turns = Math.max(0, Math.floor(Number(ttTurns) || 0));
await api.saveAgentTaskTimeoutWrapup(agentKey, ttWrapup, turns);
toast.success("任务超时收尾配置已保存(下次运行生效)");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetTaskTimeoutWrapup() {
try {
await api.resetAgentTaskTimeoutWrapup(agentKey);
toast.success("已恢复为内置默认");
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveConfig() {
try {
const n = Math.max(0, Math.floor(Number(maxTurns) || 0));
const rs = Math.max(0, Math.floor(Number(runSecs) || 0));
await api.saveAgentConfig(agentKey, { max_turns: n, run_seconds: rs, web_search: webSearch, interactive_shell: interactiveShell });
toast.success("已保存运行配置(立即生效)");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
// applyVis optimistically updates, persists, and toasts success/failure. On
// failure it reverts to the prior selection so the UI never lies about state.
async function applyVis(nextMcp: number[], nextSkill: string[], okMsg: string) {
const prevMcp = mcpVisible;
const prevSkill = skillVisible;
setMcpVisible(nextMcp);
setSkillVisible(nextSkill);
try {
await api.setAgentVisibility(agentKey, nextMcp, nextSkill);
toast.success(okMsg);
onSaved?.(); // refresh the list so the card's MCP/Skill counts stay in sync
} catch (e) {
setMcpVisible(prevMcp);
setSkillVisible(prevSkill);
toast.error("保存失败:" + (e as Error).message);
}
}
function toggleMcp(id: number) {
const on = mcpVisible.includes(id);
const name = mcp.find((m) => m.id === id)?.name ?? String(id);
applyVis(
on ? mcpVisible.filter((x) => x !== id) : [...mcpVisible, id],
skillVisible,
`${on ? "已取消" : "已开启"} MCP「${name}」可见`,
);
}
function toggleSkill(name: string) {
const on = skillVisible.includes(name);
applyVis(
mcpVisible,
on ? skillVisible.filter((x) => x !== name) : [...skillVisible, name],
`${on ? "已取消" : "已开启"} Skill「${name}」可见`,
);
}
async function toggleTool(t: Tool) {
const on = t.agents.includes(agentKey);
const nextAgents = on ? t.agents.filter((k) => k !== agentKey) : [...t.agents, agentKey];
// optimistic update
setTools((ts) => ts.map((x) => (x.key === t.key ? { ...x, agents: nextAgents } : x)));
try {
await api.saveTool(t.key, {
description: t.description,
schema: t.schema,
agents: nextAgents,
enabled: t.enabled,
});
toast.success(`${on ? "已解绑" : "已绑定"}工具「${t.key}」`);
onSaved?.(); // refresh the list so the card's 工具 count stays in sync
} catch (e) {
toast.error("保存工具绑定失败:" + (e as Error).message);
reload();
api.tools().then(setTools).catch(() => {});
}
}
if (loaded && !detail) {
return <div className="text-muted-foreground p-6 text-center text-sm">未找到 Agent:{agentKey}</div>;
}
// config is meaningless for the conversational main agent and the fixed-budget
// goals decomposer; every other agent (workers, custom assistants) honors it.
const showConfig = agentKey !== "mainagent" && agentKey !== "goals";
// web search applies to every conversational/executing agent except the one-shot
// goals decomposer; it's gated by the global master switch.
const showWebSearch = agentKey !== "goals";
// interactive shell (持久 PTY 会话工具族) 同样对除 goals 外的 agent 开放;无全局门控。
const showInteractiveShell = agentKey !== "goals";
// triggers (P3) only attach to custom agents.
const isCustom = !!detail && !detail.agent?.builtin;
return (
<Tabs defaultValue="prompt" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 w-fit">
<TabsTrigger value="prompt">配置与提示词</TabsTrigger>
<TabsTrigger value="wrapup">收尾提示词</TabsTrigger>
<TabsTrigger value="mcp">MCP</TabsTrigger>
<TabsTrigger value="skill">Skill</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
{isCustom && <TabsTrigger value="triggers">触发</TabsTrigger>}
</TabsList>
{/* 配置 + 提示词 */}
<TabsContent value="prompt" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="grid gap-4">
{(showConfig || showWebSearch || showInteractiveShell) && (
<div className="grid gap-3 rounded-md border p-3">
<div className="flex flex-wrap items-end gap-3">
{showConfig && (
<>
<div className="grid gap-1.5">
<Label htmlFor="max-turns" className="text-xs">最大循环次数(0=不限)</Label>
<Input id="max-turns" type="number" min={0} className="h-8 w-32"
value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="run-seconds" className="text-xs">运行时长(秒,0=不限)</Label>
<Input id="run-seconds" type="number" min={0} className="h-8 w-32"
value={runSecs} onChange={(e) => setRunSecs(e.target.value)} />
</div>
</>
)}
<Button size="sm" variant="outline" onClick={saveConfig}>
<SaveIcon /> 保存配置
</Button>
</div>
{showWebSearch && (
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="web-search"
checked={webSearch}
disabled={!webSearchGlobalOn}
onCheckedChange={setWebSearch}
/>
<div className="grid gap-0.5">
<Label htmlFor="web-search" className="text-sm">网络搜索</Label>
<span className="text-muted-foreground text-xs">
{webSearchGlobalOn
? "为该 Agent 开启后(点上方保存生效),可用 web_search 联网检索"
: "需先在「系统配置」开启网络搜索并配置后端,才能在此启用"}
</span>
</div>
</div>
)}
{showInteractiveShell && (
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="interactive-shell"
checked={interactiveShell}
onCheckedChange={setInteractiveShell}
/>
<div className="grid gap-0.5">
<Label htmlFor="interactive-shell" className="text-sm">交互式 Shell</Label>
<span className="text-muted-foreground text-xs">
为该 Agent 开启后(点上方保存生效),可用持久 PTY 会话工具(shell_open/send/read/close/list)驱动 msfconsole/ssh/REPL 等交互程序
</span>
</div>
</div>
)}
</div>
)}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">变量(点击插入占位符,渲染时由运行时数据替换)</Label>
<div className="flex flex-wrap gap-2">
{variables.map((v) => (
<Tooltip key={v.name}>
<TooltipTrigger asChild>
<button type="button" onClick={() => setPrompt((p) => `${p}{{.${v.name}}}`)}
className="hover:bg-muted inline-flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 font-mono text-xs">
{`{{.${v.name}}}`}
<Badge variant="secondary" className="px-1 py-0 text-[10px]">{v.source}</Badge>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-medium">{v.description}</p>
<p className="text-muted-foreground mt-1">示例:{v.example}</p>
</TooltipContent>
</Tooltip>
))}
{variables.length === 0 && <span className="text-muted-foreground text-xs">(无变量)</span>}
</div>
</div>
<Textarea className="font-mono text-xs" rows={16} value={prompt}
placeholder="留空则使用内置默认提示词" onChange={(e) => setPrompt(e.target.value)} />
<div className="flex flex-wrap gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={doPreview}>
<EyeIcon /> 预览渲染
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>渲染预览</DialogTitle>
<DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{preview}
</pre>
</DialogContent>
</Dialog>
<Button size="sm" onClick={savePrompt}>
<SaveIcon /> 保存为新版本
</Button>
<Button variant="outline" size="sm" onClick={resetPrompt}>
<RotateCcwIcon /> 恢复默认
</Button>
</div>
<Separator />
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">版本历史</Label>
<ul className="grid gap-1">
{versions.map((ver, i) => (
<li key={ver.version} className="flex items-center gap-2 rounded-md px-1 py-0.5 text-xs hover:bg-muted/50">
<span className="font-mono shrink-0">v{ver.version}</span>
{i === 0 && <Badge variant="secondary" className="px-1.5 py-0 shrink-0">当前</Badge>}
<span className="text-muted-foreground truncate flex-1">{ver.note}</span>
{ver.ts && (
<span className="text-muted-foreground/60 shrink-0 tabular-nums">
{new Date(ver.ts).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
)}
<Button variant="ghost" size="icon-sm" className="size-6 shrink-0" onClick={() => setViewVer(ver)}>
<EyeIcon className="size-3" />
</Button>
{i > 0 && versions[0] && (
<Button variant="ghost" size="icon-sm" className="size-6 shrink-0" onClick={() => setDiffVer(ver)}>
<GitCompareIcon className="size-3" />
</Button>
)}
</li>
))}
{versions.length === 0 && (
<li className="text-muted-foreground text-xs">(暂无保存的版本,使用内置默认)</li>
)}
</ul>
</div>
{/* 查看版本 dialog */}
<Dialog open={!!viewVer} onOpenChange={(o) => { if (!o) setViewVer(null); }}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
v{viewVer?.version}
{viewVer?.version === versions[0]?.version && (
<Badge variant="secondary" className="ml-2 px-1.5 py-0 align-middle">当前</Badge>
)}
</DialogTitle>
<DialogDescription>
{viewVer?.note || "(无备注)"}
{viewVer?.ts && (
<span className="ml-2 text-muted-foreground/60">
{new Date(viewVer.ts).toLocaleString("zh-CN")}
</span>
)}
</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[55vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{viewVer?.template_text || "(空)"}
</pre>
<div className="flex gap-2 justify-end">
{viewVer && viewVer.version !== versions[0]?.version && (
<Button variant="outline" size="sm" onClick={() => {
if (viewVer) { setDiffVer(viewVer); setViewVer(null); }
}}>
<GitCompareIcon className="mr-1 size-3.5" /> 与当前版本对比
</Button>
)}
<Button size="sm" onClick={() => {
if (viewVer) { setPrompt(viewVer.template_text); setViewVer(null); toast.success(`已加载 v${viewVer.version} 到编辑器,确认后点「保存为新版本」`); }
}}>
加载到编辑器
</Button>
</div>
</DialogContent>
</Dialog>
{/* 版本对比 dialog */}
<Dialog open={!!diffVer} onOpenChange={(o) => { if (!o) setDiffVer(null); }}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>版本对比:v{diffVer?.version} → v{versions[0]?.version}(当前)</DialogTitle>
<DialogDescription>
<span className="inline-flex items-center gap-3 text-xs">
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-600 dark:text-red-400">- 删除</span>
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-600 dark:text-green-400">+ 新增</span>
</span>
</DialogDescription>
</DialogHeader>
<DiffView oldText={diffVer?.template_text ?? ""} newText={versions[0]?.template_text ?? ""} />
</DialogContent>
</Dialog>
</div>
</TabsContent>
{/* 收尾提示词 */}
<TabsContent value="wrapup" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="grid gap-3">
<p className="text-muted-foreground text-xs leading-relaxed">
当此 Agent 因<b>超时</b>或<b>步数耗尽</b>被终止时,系统会注入这段「收尾提示词」跑一轮收尾:
先把已识别但未落库的内容写回,再输出一句总结(避免烂尾)。留空则使用内置默认。
</p>
<div className="flex items-center gap-2">
<Label className="text-xs">收尾提示词正文</Label>
{wrapup.trim() ? (
<Badge variant="secondary" className="px-1.5 py-0">自定义</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0">使用内置默认</Badge>
)}
</div>
<Textarea
className="font-mono text-xs"
rows={10}
value={wrapup}
placeholder={wrapupDefault || "留空则使用内置默认收尾提示词"}
onChange={(e) => setWrapup(e.target.value)}
/>
<div className="grid gap-1.5">
<Label htmlFor="wrapup-turns" className="text-xs">
收尾轮数(收尾阶段自身最多跑几轮;0=用内置默认 {wrapupTurnsDefault} 轮)
</Label>
<Input id="wrapup-turns" type="number" min={0} className="h-8 w-32"
value={wrapupTurns} onChange={(e) => setWrapupTurns(e.target.value)} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveWrapup}>保存</Button>
<Button size="sm" variant="outline" onClick={resetWrapup}>恢复默认</Button>
</div>
{wrapupDefault && (
<>
<Separator />
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">内置默认(只读,供参考)</Label>
<pre className="text-muted-foreground max-h-40 overflow-y-auto rounded-md border bg-muted/30 p-2 text-xs whitespace-pre-wrap">
{wrapupDefault}
</pre>
</div>
</>
)}
{ttSupported && (
<>
<Separator className="my-2" />
<p className="text-muted-foreground text-xs leading-relaxed">
<b>任务超时收尾</b>(与上面的 per-run 收尾是<b>两套</b>):当<b>整个任务</b>到达超时上限、即将结束时注入。
与 per-run 语义常相反(如 planner:per-run 说“别停继续规划”,任务超时说“到点停止、做最后判定”)。留空则用内置默认。
</p>
<div className="flex items-center gap-2">
<Label className="text-xs">任务超时收尾提示词正文</Label>
{ttWrapup.trim() ? (
<Badge variant="secondary" className="px-1.5 py-0">自定义</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0">使用内置默认</Badge>
)}
</div>
<Textarea
className="font-mono text-xs"
rows={10}
value={ttWrapup}
placeholder={ttWrapupDefault || "留空则使用内置默认任务超时收尾提示词"}
onChange={(e) => setTtWrapup(e.target.value)}
/>
<div className="grid gap-1.5">
<Label htmlFor="tt-turns" className="text-xs">
收尾轮数(0=用内置默认 {ttTurnsDefault} 轮)
</Label>
<Input id="tt-turns" type="number" min={0} className="h-8 w-32"
value={ttTurns} onChange={(e) => setTtTurns(e.target.value)} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveTaskTimeoutWrapup}>保存</Button>
<Button size="sm" variant="outline" onClick={resetTaskTimeoutWrapup}>恢复默认</Button>
</div>
{ttWrapupDefault && (
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">内置默认(只读,供参考)</Label>
<pre className="text-muted-foreground max-h-40 overflow-y-auto rounded-md border bg-muted/30 p-2 text-xs whitespace-pre-wrap">
{ttWrapupDefault}
</pre>
</div>
)}
</>
)}
</div>
</TabsContent>
{/* MCP 可见性 */}
<TabsContent value="mcp" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选此 Agent 可见的 MCP 服务器。</p>
<div className="grid gap-2">
{mcp.map((m) => (
<label key={m.id} className="flex items-center gap-2 rounded-md border p-2 text-sm">
<Checkbox checked={mcpVisible.includes(m.id)} onCheckedChange={() => toggleMcp(m.id)} />
{m.name}
<span className="text-muted-foreground ml-auto text-xs">{m.transport}</span>
</label>
))}
{mcp.length === 0 && <span className="text-muted-foreground text-xs">(暂无 MCP)</span>}
</div>
</TabsContent>
{/* Skill 可见性 */}
<TabsContent value="skill" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选此 Agent 可见的 Skill。</p>
<div className="grid gap-2">
{skills.map((s) => (
<label key={s.name} className="flex items-center gap-2 rounded-md border p-2 text-sm">
<Checkbox checked={skillVisible.includes(s.name)} onCheckedChange={() => toggleSkill(s.name)} />
<span className="font-mono text-xs">{s.name}</span>
{s.description && <span className="text-muted-foreground ml-auto truncate text-xs">{s.description}</span>}
</label>
))}
{skills.length === 0 && <span className="text-muted-foreground text-xs">(暂无 Skill)</span>}
</div>
</TabsContent>
{/* Tools 绑定 */}
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选绑定给此 Agent 的内置工具。</p>
<div className="grid gap-2">
{tools.map((t) => {
const isTraffic = TRAFFIC_TOOL_KEYS.has(t.key);
const gated = isTraffic && !captureOn; // traffic tools need 流量捕获 on
return (
<label
key={t.key}
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm",
gated && "opacity-60",
)}
>
<Checkbox
checked={t.agents.includes(agentKey)}
disabled={gated}
onCheckedChange={() => toggleTool(t)}
/>
<span className="font-mono text-xs">{t.key}</span>
{isTraffic && (
<Badge variant="secondary" className="px-1 py-0 text-[9px]">流量</Badge>
)}
{!t.enabled && (
<Badge variant="outline" className="text-destructive px-1 py-0 text-[9px]">已停用</Badge>
)}
{gated ? (
<span className="text-muted-foreground ml-auto text-xs">需开启流量捕获</span>
) : (
t.description && (
<span className="text-muted-foreground ml-auto line-clamp-1 max-w-[55%] text-xs">
{t.description}
</span>
)
)}
</label>
);
})}
{tools.length === 0 && <span className="text-muted-foreground text-xs">(暂无工具)</span>}
</div>
</TabsContent>
{/* 触发(P3, 仅自定义 agent) */}
{isCustom && (
<TabsContent value="triggers" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<AgentTriggersTab agentKey={agentKey} />
</TabsContent>
)}
</Tabs>
);
}
// ---------- Diff helpers ----------
type DiffLine = { type: "same" | "add" | "del"; text: string };
function computeDiff(oldText: string, newText: string): DiffLine[] {
const a = oldText.split("\n");
const b = newText.split("\n");
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
const result: DiffLine[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
result.unshift({ type: "same", text: a[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({ type: "add", text: b[j - 1] });
j--;
} else {
result.unshift({ type: "del", text: a[i - 1] });
i--;
}
}
return result;
}
function DiffView({ oldText, newText }: { oldText: string; newText: string }) {
const lines = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
return (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-xs leading-5">
{lines.map((l, idx) => (
<div
key={idx}
className={cn(
"whitespace-pre-wrap px-1",
l.type === "del" && "bg-red-500/15 text-red-700 dark:text-red-400",
l.type === "add" && "bg-green-500/15 text-green-700 dark:text-green-400",
l.type === "same" && "text-muted-foreground",
)}
>
<span className="select-none mr-1 opacity-50">{l.type === "del" ? "-" : l.type === "add" ? "+" : " "}</span>
{l.text}
</div>
))}
</pre>
);
}
// AgentTriggersTab manages a custom agent's P3 triggers: list + add + delete.
// Each trigger fires (定时/发现finding/目标达成,可多选) → a new conversation runs
// in parallel with the base user message + auto context appended by the backend.
function AgentTriggersTab({ agentKey }: { agentKey: string }) {
const [triggers, setTriggers] = React.useState<AgentTrigger[]>([]);
const [intervalSec, setIntervalSec] = React.useState("0");
const [onFinding, setOnFinding] = React.useState(false);
const [onGoalMet, setOnGoalMet] = React.useState(false);
const [onTaskTimeout, setOnTaskTimeout] = React.useState(false);
const [intervalMsg, setIntervalMsg] = React.useState("");
const [findingMsg, setFindingMsg] = React.useState("");
const [goalMsg, setGoalMsg] = React.useState("");
const [taskTimeoutMsg, setTaskTimeoutMsg] = React.useState("");
const [saving, setSaving] = React.useState(false);
const reload = React.useCallback(() => {
api.agentTriggers(agentKey).then(setTriggers).catch(() => setTriggers([]));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
async function create() {
const n = Math.max(0, Math.floor(Number(intervalSec) || 0));
if (n === 0 && !onFinding && !onGoalMet && !onTaskTimeout) {
toast.error("至少选择一种触发条件");
return;
}
setSaving(true);
try {
await api.createTrigger(agentKey, {
enabled: true,
interval_sec: n,
on_finding: onFinding,
on_goal_met: onGoalMet,
on_task_timeout: onTaskTimeout,
interval_message: intervalMsg.trim(),
finding_message: findingMsg.trim(),
goal_message: goalMsg.trim(),
task_timeout_message: taskTimeoutMsg.trim(),
});
toast.success("已添加触发器");
setIntervalSec("0");
setOnFinding(false);
setOnGoalMet(false);
setOnTaskTimeout(false);
setIntervalMsg("");
setFindingMsg("");
setGoalMsg("");
setTaskTimeoutMsg("");
reload();
} catch (e) {
toast.error("添加失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function toggleEnabled(t: AgentTrigger) {
try {
await api.updateTrigger(t.id, {
enabled: !t.enabled,
interval_sec: t.interval_sec,
on_finding: t.on_finding,
on_goal_met: t.on_goal_met,
on_task_timeout: t.on_task_timeout,
interval_message: t.interval_message,
finding_message: t.finding_message,
goal_message: t.goal_message,
task_timeout_message: t.task_timeout_message,
});
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function del(id: number) {
try {
await api.deleteTrigger(id);
reload();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
function condLabel(t: AgentTrigger): string {
const parts: string[] = [];
if (t.interval_sec > 0) parts.push(`每 ${t.interval_sec}s`);
if (t.on_finding) parts.push("发现 finding");
if (t.on_goal_met) parts.push("目标达成");
if (t.on_task_timeout) parts.push("任务超时");
return parts.join(" · ") || "(无条件)";
}
return (
<div className="grid gap-4">
<p className="text-muted-foreground text-xs">
触发器让这个自定义 Agent 自动运行:每次触发都<b>新建一个会话并行运行</b>(在「对话」页可见)。
可多选触发条件;系统会把「本次为何触发 + 相关任务/finding/目标」自动附加到你写的基础消息后面。
</p>
{/* 新增触发器 */}
<div className="grid gap-3 rounded-md border p-3">
<Label className="text-muted-foreground text-xs">新增触发器(每种条件可填各自的用户消息)</Label>
{/* 定时 */}
<div className="grid gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="tr-interval" className="text-xs">定时触发:每</Label>
<Input id="tr-interval" type="number" min={0} className="h-8 w-24"
value={intervalSec} onChange={(e) => setIntervalSec(e.target.value)} />
<span className="text-muted-foreground text-xs">秒(0=关)</span>
</div>
{Number(intervalSec) > 0 && (
<Textarea className="text-xs" rows={2} value={intervalMsg}
placeholder="定时触发时发给 agent 的话,如:巡检所有任务" onChange={(e) => setIntervalMsg(e.target.value)} />
)}
</div>
{/* finding */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onFinding} onCheckedChange={(v) => setOnFinding(!!v)} /> 发现 finding 时触发
</label>
{onFinding && (
<Textarea className="text-xs" rows={2} value={findingMsg}
placeholder="发现 finding 时发给 agent 的话(系统会附带任务与 finding 详情)" onChange={(e) => setFindingMsg(e.target.value)} />
)}
</div>
{/* 目标达成 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onGoalMet} onCheckedChange={(v) => setOnGoalMet(!!v)} /> 目标达成时触发
</label>
{onGoalMet && (
<Textarea className="text-xs" rows={2} value={goalMsg}
placeholder="目标达成时发给 agent 的话(系统会附带任务与达成的目标)" onChange={(e) => setGoalMsg(e.target.value)} />
)}
</div>
{/* 任务超时 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onTaskTimeout} onCheckedChange={(v) => setOnTaskTimeout(!!v)} /> 任务超时时触发
</label>
{onTaskTimeout && (
<Textarea className="text-xs" rows={2} value={taskTimeoutMsg}
placeholder="任务超时时发给 agent 的话(系统会附带任务编号与目标)" onChange={(e) => setTaskTimeoutMsg(e.target.value)} />
)}
</div>
<div>
<Button size="sm" onClick={create} disabled={saving}>
<SaveIcon /> 添加触发器
</Button>
</div>
</div>
{/* 已有触发器 */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">已有触发器</Label>
{triggers.length === 0 && <span className="text-muted-foreground text-xs">(暂无)</span>}
{triggers.map((t) => (
<div key={t.id} className="flex items-start gap-2 rounded-md border p-2 text-sm">
<Switch checked={t.enabled} onCheckedChange={() => toggleEnabled(t)} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{condLabel(t)}</span>
{!t.enabled && (
<Badge variant="outline" className="text-destructive px-1 py-0 text-[9px]">已停用</Badge>
)}
</div>
<div className="text-muted-foreground grid gap-0.5 text-xs">
{t.interval_sec > 0 && t.interval_message && <div className="line-clamp-1">定时:{t.interval_message}</div>}
{t.on_finding && t.finding_message && <div className="line-clamp-1">finding:{t.finding_message}</div>}
{t.on_goal_met && t.goal_message && <div className="line-clamp-1">目标:{t.goal_message}</div>}
{t.on_task_timeout && t.task_timeout_message && <div className="line-clamp-1">超时:{t.task_timeout_message}</div>}
</div>
</div>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-destructive"
onClick={() => del(t.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,497 @@
import FullCalendar, { type CalendarOptions, type DayCellInfo } from '@fullcalendar/react'
import '@fullcalendar/react/skeleton.css'
import { cn } from '@/lib/utils'
const xxsTextClass = 'text-[0.6875rem]/[1.090909]'
const blockPointerResizerClass = 'absolute hidden group-hover:block'
const rowPointerResizerClass = `${blockPointerResizerClass} inset-y-0 w-2`
const columnPointerResizerClass = `${blockPointerResizerClass} inset-x-0 h-2`
const blockTouchResizerClass = 'absolute size-2 border border-(--fc-event-color) bg-background rounded-full'
const rowTouchResizerClass = `${blockTouchResizerClass} top-1/2 -mt-1`
const columnTouchResizerClass = `${blockTouchResizerClass} left-1/2 -ml-1`
const tallDayCellBottomClass = 'min-h-3'
const getShortDayCellBottomClass = (info: DayCellInfo) => cn(
!info.isNarrow && 'min-h-0.5'
)
const dayRowCommonClasses: CalendarOptions = {
/* Day Row > List-Item Event
----------------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'mb-px p-px rounded-sm',
info.isNarrow ? 'mx-0.5' : 'mx-1',
info.isSelected
? 'bg-foreground/5'
: 'hover:bg-foreground/5',
),
listItemEventInnerClass: (info) => cn(
'flex flex-row items-center justify-between',
info.isNarrow
? `py-px ${xxsTextClass}`
: 'py-0.5 text-xs',
),
listItemEventTimeClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'order-1 whitespace-nowrap overflow-hidden shrink-1',
),
listItemEventTitleClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'font-medium whitespace-nowrap overflow-hidden shrink-100',
info.timeText && 'text-ellipsis',
),
/* Day Row > Row Event
----------------------------------------------------------------------------------------------- */
rowEventClass: (info) => cn(
info.isStart && (info.isNarrow ? 'ms-0.5' : 'ms-1'),
info.isEnd && (info.isNarrow ? 'me-0.5' : 'me-1'),
),
rowEventInnerClass: (info) => cn(info.isNarrow ? 'py-px' : 'py-0.5'),
/* Day Row > More-Link
----------------------------------------------------------------------------------------------- */
rowMoreLinkClass: (info) => cn(
'mb-px border rounded-sm',
info.isNarrow
? 'mx-0.5 border-primary hover:bg-foreground/5'
: 'self-start mx-1 border-transparent bg-foreground/5 hover:bg-foreground/10',
),
rowMoreLinkInnerClass: (info) => cn(
info.isNarrow
? `p-px ${xxsTextClass}`
: 'p-0.5 text-xs',
),
}
export type EventCalendarViewProps =
CalendarOptions &
Required<Pick<CalendarOptions, 'popoverCloseContent'>> // ensure callers define icons
export function EventCalendarViews({
height,
views: userViews,
...restOptions
}: EventCalendarViewProps) {
return (
<FullCalendar
height={height}
/* Abstract Event
----------------------------------------------------------------------------------------- */
eventShortHeight={50}
eventColor='var(--primary)'
eventContrastColor='var(--primary-foreground)'
eventClass={(info) => cn(
info.isSelected
? ['outline-3', info.isDragging && 'shadow-lg']
: 'focus-visible:outline-3',
'outline-ring/50',
)}
/* Background Event
----------------------------------------------------------------------------------------- */
backgroundEventColor='var(--chart-3)'
backgroundEventClass='not-print:bg-[color-mix(in_oklab,var(--fc-event-color)_10%,transparent)] print:border-1 print:border-(--fc-event-color)'
backgroundEventTitleClass={(info) => cn(
'opacity-50 italic',
(info.isNarrow || info.isShort)
? `p-1 ${xxsTextClass}`
: 'p-2 text-xs',
)}
/* List-Item Event
----------------------------------------------------------------------------------------- */
listItemEventTimeClass='text-muted-foreground'
/* Block Event
----------------------------------------------------------------------------------------- */
blockEventClass={(info) => cn(
'group relative border-transparent print:border-(--fc-event-color) bg-(--fc-event-color) print:bg-white hover:bg-[color-mix(in_oklab,var(--fc-event-color)_92%,var(--fc-event-contrast-color))]',
info.isInteractive && 'active:bg-[color-mix(in_oklab,var(--fc-event-color)_85%,var(--fc-event-contrast-color))]',
(info.isDragging && !info.isSelected) && 'opacity-75',
)}
blockEventInnerClass='text-(--fc-event-contrast-color) print:text-black'
blockEventTimeClass='whitespace-nowrap overflow-hidden shrink-1'
blockEventTitleClass='whitespace-nowrap overflow-hidden shrink-100'
/* Row Event
----------------------------------------------------------------------------------------- */
rowEventClass={(info) => cn(
'mb-px border-y',
info.isStart && 'rounded-s-sm border-s',
info.isEnd && 'rounded-e-sm border-e',
)}
rowEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-start-1',
]
)}
rowEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-end-1',
]
)}
rowEventInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? xxsTextClass : 'text-xs',
)}
rowEventTimeClass={(info) => cn(
info.isNarrow ? 'ps-0.5' : 'ps-1'
)}
rowEventTitleClass={(info) => cn(
info.isNarrow ? 'px-0.5' : 'px-1',
'font-medium',
)}
/* Column Event
----------------------------------------------------------------------------------------- */
columnEventClass={(info) => cn(
'border-x ring ring-background',
info.isStart && ['border-t rounded-t-lg', info.isNarrow ? 'mt-px' : 'mt-0.5'],
info.isEnd && ['border-b rounded-b-lg', info.isNarrow ? 'mb-px' : 'mb-0.5'],
)}
columnEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-top-1',
]
)}
columnEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-bottom-1',
]
)}
columnEventInnerClass={(info) => cn(
'flex',
info.isShort
? 'flex-row items-center gap-1 p-1'
: ['flex-col', info.isNarrow ? 'px-1 py-0.5' : 'px-2 py-1'],
(info.isNarrow || info.isShort) ? xxsTextClass : 'text-xs',
)}
columnEventTimeClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'pt-0.5' : 'pt-1')
)}
columnEventTitleClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'py-0.5' : 'py-1'),
'font-medium',
)}
/* More-Link
----------------------------------------------------------------------------------------- */
moreLinkClass="focus-visible:outline-3 outline-ring/50"
moreLinkInnerClass='whitespace-nowrap overflow-hidden'
columnMoreLinkClass="my-0.5 border border-transparent print:border-black rounded-md bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))] hover:bg-[color-mix(in_oklab,var(--foreground)_13%,var(--background))] print:bg-white ring ring-background"
columnMoreLinkInnerClass={(info) => cn(
info.isNarrow
? `p-0.5 ${xxsTextClass}`
: 'p-1 text-xs',
)}
/* Day Header
----------------------------------------------------------------------------------------- */
dayHeaderClass={(info) => cn(
'justify-center',
info.inPopover ? 'border-b bg-foreground/5' :
info.isMajor && 'border border-foreground/20',
)}
dayHeaderInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? 'text-xs' : 'text-sm',
info.inPopover ? [
'm-1.5 px-1 py-0.5 rounded-sm font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.dayNumberText ? [
'mx-0.5 my-1.5 py-0.5 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.isToday ? [
'mx-2 my-2.5 h-6 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : (
'group mx-2 my-2 h-7 outline-none'
)
)}
dayHeaderContent={(info) => (
(info.inPopover || !info.dayNumberText || !info.isToday) ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'first:-ms-1 last:-me-1 size-7 rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
]
: 'text-muted-foreground',
)}
>{textPart.value}</span>
))}
</>
)
)}
/* Day Cell
----------------------------------------------------------------------------------------- */
dayCellClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
)}
dayCellTopClass={(info) => cn(
info.isNarrow ? 'min-h-0.5' : 'min-h-1',
'flex flex-row justify-end',
)}
dayCellTopInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow
? `my-px h-5 ${xxsTextClass}`
: 'my-1 h-6 text-sm',
!info.isToday
? [
'rounded-s-sm whitespace-nowrap',
!info.isOther && 'font-semibold',
info.isNarrow ? 'px-1' : 'px-2',
!info.monthText && 'text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
]
: [
'group outline-none',
info.isNarrow
? 'mx-px'
: 'mx-2',
]
)}
dayCellTopContent={(info) => (
!info.isToday ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
info.isNarrow
? 'size-5'
: 'size-6 first:-ms-1 last:-me-1',
]
: (!info.monthText && 'text-muted-foreground'),
)}
>{textPart.value}</span>
))}
</>
)
)}
dayCellInnerClass={(info) => cn(info.inPopover && 'p-2')}
/* Popover
----------------------------------------------------------------------------------------- */
popoverClass='border rounded-md overflow-hidden shadow-lg m-1 bg-popover text-popover-foreground min-w-55'
popoverCloseClass="group absolute top-1.5 end-1.5 p-0.5 rounded-sm hover:bg-foreground/5 focus-visible:outline-3 outline-ring/50"
/* Lane
----------------------------------------------------------------------------------------- */
dayLaneClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
info.isDisabled && 'bg-foreground/3',
)}
dayLaneInnerClass={(info) => (
info.isStack
? 'm-1'
: info.isNarrow ? 'mx-px' : 'mx-0.5'
)}
slotLaneClass={(info) => cn(
'border',
info.isMinor && 'border-dotted',
)}
/* List Day
----------------------------------------------------------------------------------------- */
listDayClass={(info) => cn(
'flex flex-col',
!info.isLast && 'border-b',
)}
listDayHeaderClass="-mb-px border-b bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] flex flex-row items-center justify-between"
listDayHeaderInnerClass={(info) => cn(
'm-1.5 px-1.5 py-0.5 rounded-sm text-sm',
!info.level && 'font-semibold',
(!info.level && info.isToday)
? [
'bg-chart-1/50',
info.hasNavLink && 'hover:bg-chart-1/60 focus-visible:outline-3 outline-ring/50',
]
: info.hasNavLink && 'hover:bg-foreground/5',
)}
listDayBodyClass='mt-px px-1.5 py-2 gap-2'
/* Single Month (in Multi-Month)
----------------------------------------------------------------------------------------- */
singleMonthClass={(info) => cn(
info.multiMonthColumns > 1 && 'm-3',
(info.multiMonthColumns === 1 && !info.isLast) && 'border-b',
)}
singleMonthHeaderClass={(info) => cn(
info.multiMonthColumns > 1 ? 'pb-2' : 'py-1 border-b bg-background',
'items-center',
)}
singleMonthHeaderInnerClass={(info) => cn(
'px-1.5 py-0.5 rounded-sm text-base font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
)}
/* Misc Table
----------------------------------------------------------------------------------------- */
tableBodyClass='bg-background'
fillerClass='border opacity-50'
dayNarrowWidth={100}
dayHeaderRowClass='border'
dayRowClass='border'
slotHeaderRowClass='border'
slotHeaderInnerClass='text-muted-foreground'
/* Misc Content
----------------------------------------------------------------------------------------- */
navLinkClass="focus-visible:outline-3 outline-ring/50"
inlineWeekNumberClass={(info) => cn(
'absolute start-0 whitespace-nowrap rounded-e-sm text-muted-foreground',
info.isNarrow
? `top-0.5 my-px p-0.5 ${xxsTextClass}`
: 'top-1 p-1 text-xs',
info.hasNavLink && 'hover:bg-foreground/5',
)}
highlightClass='bg-chart-1/20'
nonBusinessHoursClass='bg-foreground/3'
nowIndicatorLineClass='-m-px border-1 border-destructive'
nowIndicatorDotClass="-m-[6px] border-6 border-destructive size-0 rounded-full ring-2 ring-background"
/* View-Specific Options
----------------------------------------------------------------------------------------- */
views={{
...userViews,
dayGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: 'border-b',
dayCellBottomClass: getShortDayCellBottomClass,
...userViews?.dayGrid,
},
multiMonth: {
...dayRowCommonClasses,
className: 'bg-foreground/3',
tableHeaderClass: (info) => cn(info.multiMonthColumns === 1 && 'bg-background'),
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: (info) => cn(info.multiMonthColumns === 1 && 'border-b'),
dayCellBottomClass: getShortDayCellBottomClass,
tableBodyClass: (info) => cn(info.multiMonthColumns > 1 && 'border rounded-sm overflow-hidden'),
...userViews?.multiMonth,
},
timeGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : 'center',
dayHeaderDividerClass: (info) => cn(
'border-b',
!info.options.allDaySlot && 'border-foreground/20 not-print:shadow-sm',
),
dayCellBottomClass: tallDayCellBottomClass,
/* TimeGrid > Week Number Header
------------------------------------------------------------------------------------- */
weekNumberHeaderClass: 'items-center justify-end',
weekNumberHeaderInnerClass: (info) => cn(
'mx-0.5 h-6 px-1.5 text-muted-foreground flex flex-row items-center rounded-sm',
info.isNarrow ? 'text-xs' : 'text-sm',
info.hasNavLink && 'hover:bg-foreground/5',
),
/* TimeGrid > All-Day Header
------------------------------------------------------------------------------------- */
allDayHeaderClass: 'items-center',
allDayHeaderInnerClass: (info) => cn(
'm-2 text-muted-foreground',
info.isNarrow ? xxsTextClass : 'text-xs',
),
allDayDividerClass: 'border-b border-foreground/20 not-print:shadow-sm',
/* TimeGrid > Slot Header
------------------------------------------------------------------------------------- */
slotHeaderClass: 'justify-end',
slotHeaderInnerClass: (info) => cn(
'relative m-2',
info.isNarrow
? `-top-3.5 ${xxsTextClass}`
: '-top-4 text-xs',
info.isFirst && 'hidden',
),
slotHeaderDividerClass: 'border-e',
...userViews?.timeGrid,
},
list: {
/* List-View > List-Item Event
------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'group py-1 rounded-sm hover:bg-muted/50',
info.isInteractive && 'focus-visible:bg-muted/50 -outline-offset-3',
),
listItemEventBeforeClass: 'w-1.5 bg-(--fc-event-color) rounded-full',
listItemEventInnerClass: '[display:contents]',
listItemEventTimeClass: "-order-1 shrink-0 w-1/2 max-w-60 px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm",
listItemEventTitleClass: (info) => cn(
'grow min-w-0 px-4 py-2 whitespace-nowrap overflow-hidden text-sm',
info.event.url && 'group-hover:underline',
),
/* No-Events Screen
------------------------------------------------------------------------------------- */
noEventsClass: 'grow flex flex-col items-center justify-center',
noEventsInnerClass: 'py-15 text-muted-foreground',
...userViews?.list,
},
}}
{...restOptions}
/>
)
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import { format, subDays } from "date-fns";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface DateRangePickerProps {
value?: DateRange;
onChange?: (value: DateRange | undefined) => void;
}
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const [internalDateRange, setInternalDateRange] = React.useState<DateRange | undefined>(() => {
const to = new Date();
const from = subDays(to, 29);
return { from, to };
});
const dateRange = value ?? internalDateRange;
let dateRangeLabel = "Select date";
if (dateRange?.from) {
dateRangeLabel = format(dateRange.from, "d MMM yyyy");
}
if (dateRange?.from && dateRange.to) {
dateRangeLabel = `${format(dateRange.from, "d MMM yyyy")} - ${format(dateRange.to, "d MMM yyyy")}`;
}
const handleDateChange = (nextValue: DateRange | undefined) => {
if (!value) {
setInternalDateRange(nextValue);
}
onChange?.(nextValue);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" id="date" className="font-normal">
{dateRangeLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
// Tailwind-styled element overrides (no typography plugin in this project, so we
// style each element). `node` is stripped — it's not a valid DOM attribute.
const components: Components = {
h1: ({ node, ...p }) => <div className="mt-2.5 text-base font-semibold" {...p} />,
h2: ({ node, ...p }) => <div className="mt-2 text-sm font-semibold" {...p} />,
h3: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
h4: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
p: ({ node, ...p }) => <p className="leading-relaxed" {...p} />,
ul: ({ node, ...p }) => <ul className="my-1 list-disc space-y-0.5 pl-5" {...p} />,
ol: ({ node, ...p }) => <ol className="my-1 list-decimal space-y-0.5 pl-5" {...p} />,
strong: ({ node, ...p }) => <strong className="font-semibold" {...p} />,
a: ({ node, ...p }) => (
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...p} />
),
hr: () => <hr className="my-2 border-border" />,
blockquote: ({ node, ...p }) => (
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
),
pre: ({ node, ...p }) => (
<pre className="my-1.5 overflow-auto rounded-md bg-muted/70 p-2.5 text-xs leading-relaxed" {...p} />
),
code: ({ node, className, children, ...rest }) => {
const block = /language-/.test(className || "");
return block ? (
<code className={"font-mono text-xs " + (className || "")} {...rest}>
{children}
</code>
) : (
<code className="break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" {...rest}>
{children}
</code>
);
},
table: ({ node, ...p }) => (
<div className="my-1.5 overflow-x-auto">
<table className="w-full border-collapse text-xs" {...p} />
</div>
),
th: ({ node, ...p }) => (
<th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />
),
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
};
export function Markdown({ text }: { text: string }) {
return (
<div className="min-w-0 space-y-1.5 text-sm [&>*:first-child]:mt-0">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import type * as React from "react";
import type { SimpleIcon as SimpleIconType } from "simple-icons";
import { cn } from "@/lib/utils";
type SimpleIconProps = {
icon: SimpleIconType;
className?: string;
} & React.SVGProps<SVGSVGElement>;
export function SimpleIcon({ icon, className, ...props }: SimpleIconProps) {
const { title, path } = icon;
return (
<svg
viewBox="0 0 24 24"
aria-label={title}
aria-hidden="false"
focusable="false"
className={cn("size-5 fill-foreground", className)}
{...props}
>
<title>{title}</title>
<path d={path} />
</svg>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import {
statusMeta,
toneClasses,
toneDot,
type StatusDomain,
type Tone,
} from "@/lib/status";
export function StatusBadge({
domain,
value,
dot = false,
className,
}: {
domain: StatusDomain;
value: string;
dot?: boolean;
className?: string;
}) {
const meta = statusMeta(domain, value);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
toneClasses[meta.tone],
className,
)}
>
{dot && (
<span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />
)}
{meta.label}
</span>
);
}
export function ToneDot({ tone }: { tone: Tone }) {
return <span className={cn("size-2 rounded-full", toneDot[tone])} />;
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TablePaginationProps {
page: number; // 1-based
pageSize: number;
total: number; // total (after filtering)
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
pageSizeOptions?: number[];
}
// pageWindows returns the sequence of page numbers / ellipsis to render.
function pageWindows(page: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | "...")[] = [1];
if (page > 3) out.push("...");
const lo = Math.max(2, page - 1);
const hi = Math.min(total - 1, page + 1);
for (let i = lo; i <= hi; i++) out.push(i);
if (page < total - 2) out.push("...");
out.push(total);
return out;
}
export function TablePagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, total);
return (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 lg:px-6">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Select
value={String(pageSize)}
onValueChange={(v) => {
onPageSizeChange(Number(v));
onPageChange(1);
}}
>
<SelectTrigger size="sm" className="h-7 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<span>条/页</span>
{total > 0 ? (
<span className="tabular-nums">
{from}–{to} / 共 {total} 条
</span>
) : (
<span>共 0 条</span>
)}
</div>
{totalPages > 1 && (
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={page === 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeftIcon className="size-4" />
</Button>
</PaginationItem>
{pageWindows(page, totalPages).map((p, i) =>
p === "..." ? (
<PaginationItem key={`el-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
variant={p === page ? "outline" : "ghost"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
</PaginationItem>
),
)}
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={page === totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRightIcon className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import * as React from "react";
import { ListTodo } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// TodoPopover shows the latest TodoWrite state of a session (chat conversation or
// task worker/planner replay). The full todo list isn't in the activity list
// (detail is lazy), so on open it fetches the detail of the most-recent TodoWrite
// call — by seq — and parses its {todos:[…]} JSON. Purely client-side; the caller
// supplies the seq + a detail fetcher (conversation or exploration endpoint).
export function TodoPopover({
seq,
fetchDetail,
}: {
seq: number | null;
fetchDetail: (seq: number) => Promise<string>;
}) {
const [open, setOpen] = React.useState(false);
const [todos, setTodos] = React.useState<{ content: string; status: string }[] | null>(null);
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
const load = React.useCallback(async () => {
if (seq == null) return;
setLoading(true);
setErr("");
try {
const detail = await fetchDetail(seq);
const start = detail.indexOf("{"); // detail 可能带 "TodoWrite " 前缀
const parsed = JSON.parse(start >= 0 ? detail.slice(start) : detail);
setTodos(Array.isArray(parsed?.todos) ? parsed.todos : []);
} catch {
setErr("解析 Todo 失败");
setTodos(null);
} finally {
setLoading(false);
}
}, [seq, fetchDetail]);
// refetch on each open — todos change as the run progresses.
React.useEffect(() => {
if (open) load();
}, [open, load]);
const disabled = seq == null;
const MARK: Record<string, string> = { pending: "☐", in_progress: "▶", completed: "✔" };
return (
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
title={disabled ? "本会话暂无 Todo" : "查看最近 Todo"}
className="text-muted-foreground/70 hover:text-primary flex items-center gap-0.5 text-xs disabled:pointer-events-none disabled:opacity-40"
>
<ListTodo className="size-3" />
Todo
</button>
</PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-80 overflow-auto p-2">
<p className="text-muted-foreground px-1 pb-1 text-[11px] font-medium">
最近 Todo{loading ? " · 加载中…" : ""}
</p>
{err && <p className="text-destructive px-1 text-xs">{err}</p>}
{todos && todos.length === 0 && !loading && (
<p className="text-muted-foreground px-1 text-xs">(空)</p>
)}
<ul className="space-y-0.5">
{(todos ?? []).map((t, i) => (
<li
key={`${i}:${t.content}`}
className={cn("flex gap-1.5 px-1 text-xs", t.status === "completed" && "text-muted-foreground line-through")}
>
<span className="shrink-0">{MARK[t.status] ?? "☐"}</span>
<span className="break-words">{t.content}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
+561
View File
@@ -0,0 +1,561 @@
"use client";
import * as React from "react";
import {
CheckIcon,
ChevronDown,
ChevronRight,
CrosshairIcon,
Flag,
MessageSquare,
ShieldAlertIcon,
Terminal,
UserIcon,
Wrench,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import type { Activity } from "@/lib/types";
// ---- per-agent lane color (planner + work#1/#2/#3 …) ---------------------------
const workerColors = [
"bg-sky-600",
"bg-violet-600",
"bg-teal-600",
"bg-pink-600",
"bg-orange-600",
];
function workerColor(name: string): string {
if (name === "planner") return "bg-amber-600"; // the intent generator, distinct
if (name === "mainagent") return "bg-primary";
const m = /#(\d+)/.exec(name);
const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0;
return workerColors[Math.max(0, i)];
}
const chip = (worker: string) =>
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
// a run of consecutive conversational steps (text/thinking/result) from the same
// agent is one "message".
type Group =
| { type: "user"; key: number; step: Activity; intent?: boolean }
| { type: "answer"; key: number; step: Activity }
| { type: "round"; key: number; label: string }
| { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity }
| { type: "msg"; key: number; worker: string; steps: Activity[] }
| { type: "intercept"; key: number; step: Activity };
function groupSteps(steps: Activity[], chat: boolean): Group[] {
const out: Group[] = [];
const byToolId = new Map<string, Extract<Group, { type: "tool" }>>();
for (const s of steps) {
if (s.kind === "usage") continue; // live token-usage marker — not a rendered step
if (s.kind === "round") {
out.push({ type: "round", key: s.seq, label: s.summary || "新一轮" }); // planner round boundary
continue;
}
if (s.kind === "intercept_request") {
out.push({ type: "intercept", key: s.seq, step: s });
continue;
}
if (s.kind === "user" || s.kind === "intent") {
// human turn OR the LLM-generated intent leading a worker session — both are
// right-aligned bubbles; `intent` swaps the avatar to a non-human icon.
out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" });
continue;
}
// In a chat (main agent) the assistant's text/result IS the answer — render it
// full (markdown), never collapsed. thinking still folds into a compact block.
if (s.kind === "result" || (chat && s.kind === "text")) {
out.push({ type: "answer", key: s.seq, step: s });
continue;
}
if (s.kind === "tool_use") {
const g: Extract<Group, { type: "tool" }> = { type: "tool", key: s.seq, worker: s.worker, use: s };
if (s.tool_use_id) byToolId.set(s.tool_use_id, g);
out.push(g);
continue;
}
if (s.kind === "tool_result") {
// bind to its tool_use by id (NOT adjacency — tools can run in parallel)
const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined;
if (g && !g.result) g.result = s;
else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result
continue;
}
const last = out[out.length - 1];
if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s);
else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] });
}
return out;
}
const kindLabel = (k: string) => (k === "thinking" ? "推理" : k === "result" ? "总结" : "说明");
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
// the command itself instead of JSON; other tools fall back to the raw text.
function toolInputText(tool: string, raw: string): string {
if (tool !== "Bash") return raw;
// full input (expanded detail): parse and pull the command out.
try {
const o = JSON.parse(raw);
if (o && typeof o.command === "string") return o.command;
} catch {
// collapsed-row summaries are truncated to ~200 chars by the backend, so the
// JSON tail is cut off and JSON.parse fails — fall through and extract the
// "command" field by hand, tolerating the missing closing quote.
}
const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/);
if (m) {
try {
// re-wrap the captured body and parse to unescape \n, \", \\, etc.
return JSON.parse('"' + m[1] + '"');
} catch {
// truncated mid-escape — unescape the common sequences best-effort.
return m[1].replace(/\\(["\\/nrt])/g, (_s, c) =>
c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c,
);
}
}
return raw;
}
// InterceptCard renders an inline intercept_request approval card. The pending_id
// is extracted from the summary (format: "工具 X 请求审批 (#N)") so buttons are
// available immediately without waiting for the detail load.
function InterceptCard({
step,
getDetail,
}: {
step: Activity;
getDetail: (seq: number) => Promise<string>;
}) {
// extract pending_id from summary: "工具 Bash 请求审批 (#42)"
const pendingId = React.useMemo(() => {
const m = /\(#(\d+)\)/.exec(step.summary);
return m ? parseInt(m[1], 10) : null;
}, [step.summary]);
const toolName = React.useMemo(() => {
const m = /工具\s+(\S+)\s+请求/.exec(step.summary);
return m ? m[1] : step.summary;
}, [step.summary]);
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null);
const [deciding, setDeciding] = React.useState(false);
// Load persisted detail JSON + check the real current status from the backend
// so that a page refresh shows the already-decided state instead of re-offering buttons.
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((raw) => {
if (!live || !raw) return;
try { setDetail(JSON.parse(raw)); } catch { /* ignore */ }
})
.catch(() => {/* ignore */});
return () => { live = false; };
}, [step.seq, getDetail]);
React.useEffect(() => {
if (!pendingId) return;
let live = true;
api.interceptGetOne(pendingId)
.then((p) => {
if (!live) return;
if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout");
})
.catch(() => {/* ignore */});
return () => { live = false; };
}, [pendingId]);
async function decide(decision: "allowed" | "denied") {
if (!pendingId || deciding) return;
setDeciding(true);
try {
await api.interceptDecide(pendingId, decision);
setDecided(decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(false);
}
}
const inputStr = detail?.input
? JSON.stringify(detail.input).slice(0, 200)
: null;
return (
<div className="my-2 rounded-lg border border-amber-400/50 bg-amber-50/40 dark:bg-amber-950/15 p-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<ShieldAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 font-medium">
<span className="text-amber-700 dark:text-amber-400">审批请求</span>
<code className="rounded bg-amber-100 dark:bg-amber-900/50 px-1 font-mono text-amber-800 dark:text-amber-300">
{toolName}
</code>
{pendingId && (
<span className="text-muted-foreground">#{pendingId}</span>
)}
</div>
{inputStr && (
<p className="font-mono text-muted-foreground truncate">{inputStr}</p>
)}
</div>
</div>
{decided ? (
<span className={
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
(decided === "allowed"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
: decided === "timeout"
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400")
}>
{decided === "allowed" ? "已允许" : decided === "timeout" ? "已超时" : "已拒绝"}
</span>
) : (
<div className="flex shrink-0 gap-1.5">
<Button
size="sm"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("allowed")}
>
<CheckIcon className="h-3 w-3" />
允许
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("denied")}
>
<XIcon className="h-3 w-3" />
拒绝
</Button>
</div>
)}
</div>
</div>
);
}
// ToolBlock renders ONE tool call: the command and its result bound into a single
// row (collapsed shows the command + a status/result preview; expand shows the
// full input AND output together). Lazy-loads both details on expand.
function ToolBlock({
group,
getDetail,
showWorker,
}: {
group: Extract<Group, { type: "tool" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<string | null>(null);
// what we last loaded, keyed by the underlying step seqs. When the tool result
// arrives after we expanded mid-run (command only), this key changes and the
// effect below re-fetches — so the output shows up instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const { use, result } = group;
const toolName = use?.tool || result?.tool || "工具";
const ToolIcon = toolName === "Bash" ? Terminal : Wrench;
const running = !result;
const ok = !!result && !result.is_error;
const statusTone = running
? "text-muted-foreground"
: ok
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400";
const rawCmd =
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : use?.summary ?? "";
const cmd = toolInputText(toolName, rawCmd);
// status only — the full result lives behind the expand (【输出】), not previewed inline
const statusText = running ? "执行中…" : ok ? "✓" : "✕ 失败";
// key over the seqs we'd load; changes when the result (or command) arrives.
const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`;
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
const segs: { label: string; seq: number }[] = [];
if (use) segs.push({ label: "命令", seq: use.seq });
if (result) segs.push({ label: "输出" + (result.is_error ? " ✕" : " ✓"), seq: result.seq });
Promise.all(
segs.map((x) =>
getDetail(x.seq)
.then((d) => d || "(空)")
.catch(() => "(加载失败)"),
),
).then((parts) => {
if (!live) return;
setDetail(
segs
.map((x, i) => `【${x.label}】\n${x.label === "命令" ? toolInputText(toolName, parts[i]) : parts[i]}`)
.join("\n\n"),
);
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, use, result, getDetail]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<button onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<ToolIcon className={"mt-0.5 size-3.5 shrink-0 " + (running ? "text-sky-600 dark:text-sky-400" : statusTone)} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className="shrink-0 font-medium text-sky-600 dark:text-sky-400">{toolName}</span>
{cmd && <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{cmd}</span>}
<span className={"ml-auto shrink-0 font-medium " + statusTone}>{statusText}</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? "加载中…"}
</pre>
)}
</div>
);
}
// MessageBlock renders a coalesced agent message (merged streaming text/thinking/
// result fragments into one block) so the conversation reads as messages, not rows.
function MessageBlock({
group,
getDetail,
showWorker,
}: {
group: Extract<Group, { type: "msg" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<string | null>(null);
// like ToolBlock: keyed by the group's step seqs so streamed steps arriving
// after an early expand re-fetch instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const speak = group.steps.filter((s) => s.kind !== "thinking");
const hasThinking = group.steps.some((s) => s.kind === "thinking");
const isError = group.steps.some((s) => s.is_error);
const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…";
const Icon = isError ? Flag : MessageSquare;
const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground";
const detailKey = group.steps.map((s) => s.seq).join(",");
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
Promise.all(
group.steps.map((s) =>
getDetail(s.seq)
.then((d) => d || s.summary)
.catch(() => s.summary),
),
).then((parts) => {
if (!live) return;
setDetail(group.steps.map((s, i) => `【${kindLabel(s.kind)}】\n${parts[i]}`).join("\n\n"));
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, group.steps, getDetail]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<button onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<Icon className={"mt-0.5 size-3.5 shrink-0 " + tone} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className={"min-w-0 flex-1 truncate " + tone}>
{body}
{hasThinking && <span className="ml-1 text-[10px] text-muted-foreground">· 含推理</span>}
</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? "加载中…"}
</pre>
)}
</div>
);
}
// UserRow renders a right-aligned chat bubble: either a human turn (the message you
// sent the main agent) or, in a worker session, the LLM-generated intent that leads
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
// summary is a truncated first line, so the full message is pulled from the detail
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise<string> }) {
const Icon = intent ? CrosshairIcon : UserIcon;
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [step.seq, getDetail, step.summary]);
return (
<div className="mt-3 mb-2 flex justify-end gap-2">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{full ?? step.summary}
</div>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
</div>
);
}
// AnswerBlock renders the agent's FINAL answer (kind="result") in full — never
// collapsed. The summary is a truncated first line, so the full text is pulled
// from the detail and shown inline.
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [step.seq, getDetail, step.summary]);
return (
<div className="mb-2 mt-1 flex">
<div
className={
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
(step.is_error ? "text-sm text-red-600 dark:text-red-400" : "")
}
>
{step.is_error ? (
<span className="whitespace-pre-wrap">{full ?? step.summary}</span>
) : (
<Markdown text={full ?? step.summary} />
)}
</div>
</div>
);
}
// ExecView renders an agent execution replay (planner / worker / main agent) in
// the compact, grouped, expand-to-detail format — thinking, tool calls/results,
// and (for the main agent) the human turns. Worker lane chips show only when the
// view actually mixes agents.
function ExecView({
activity,
taskId,
chat,
fetchDetail,
}: {
activity: Activity[];
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
}) {
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
// its own (conversation-scoped) fetcher instead.
const getDetail = React.useCallback(
(seq: number) =>
fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? ""),
[fetchDetail, taskId],
);
return (
<div className="flex flex-col">
{groupSteps(activity, !!chat).map((g) =>
g.type === "round" ? (
<div key={"r" + g.key} className="my-2 flex items-center gap-2 text-[10px] font-medium text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{g.label}
<span className="h-px flex-1 bg-border" />
</div>
) : g.type === "user" ? (
<UserRow key={"u" + g.key} step={g.step} intent={g.intent} getDetail={getDetail} />
) : g.type === "answer" ? (
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
) : g.type === "tool" ? (
<ToolBlock key={"t" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
) : g.type === "intercept" ? (
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
) : (
<MessageBlock key={"m" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
),
)}
</div>
);
}
// Transcript renders a session's activity as a compact grouped execution replay:
// human turns, thinking, tool calls (command+result paired), and messages — for
// the main agent (interactive) and worker/planner (read-only) alike.
export function Transcript({
activity,
live,
taskId,
chat,
fetchDetail,
}: {
activity: Activity[];
live?: boolean;
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
}) {
return (
<div className="flex flex-col gap-1">
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} />
{live && (
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
<span className="flex gap-1">
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500" />
</span>
实时流式中…
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+199
View File
@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+204
View File
@@ -0,0 +1,204 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
)
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}
+112
View File
@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
asChild = false,
className,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="bubble-content"
className={cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
)}
{...props}
/>
)
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+222
View File
@@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+242
View File
@@ -0,0 +1,242 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
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
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
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
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}
+373
View File
@@ -0,0 +1,373 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
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
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
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-hidden [&_.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]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
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
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
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]?.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
className={cn(
"grid min-w-32 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
.filter((item) => item.type !== "none")
.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={index}
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 != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
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>
)
}
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]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+299
View File
@@ -0,0 +1,299 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+195
View File
@@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
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}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+263
View File
@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+168
View File
@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import { Direction } from "radix-ui"
function DirectionProvider({
dir,
direction,
children,
}: React.ComponentProps<typeof Direction.DirectionProvider> & {
direction?: React.ComponentProps<typeof Direction.DirectionProvider>["dir"]
}) {
return (
<Direction.DirectionProvider dir={direction ?? dir}>
{children}
</Direction.DirectionProvider>
)
}
const useDirection = Direction.useDirection
export { DirectionProvider, useDirection }
+134
View File
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+269
View File
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+156
View File
@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+87
View File
@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "lucide-react"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50",
containerClassName
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
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>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<MinusIcon
/>
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more