chore: migrate from my-content-site (Next.js) to Vite-based aura-web

- Remove legacy my-content-site Next.js scaffold
- Add new Vite project structure (src/, public/, index.html, vite.config.ts)
- Add Docker support (Dockerfile, docker-compose.yml, nginx.conf)
- Add .env.example, .gitignore and updated README
main
sp mac bookpro 2605 2026-05-24 16:19:15 +08:00
parent 6aae76c636
commit 607b9bb9e7
67 changed files with 13525 additions and 6997 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
# 前端容器对外暴露的端口
HTTP_PORT=8080
# 构建期注入:编译后的前端调用的后端地址
# - 本机直连后端http://host.docker.internal:4001
# - 与 aura-server 同 docker 网络时http://server:4001
VITE_API_TARGET=http://host.docker.internal:4001

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
*.log
.env
.env.local
tsconfig.tsbuildinfo

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# 前端构建Vite 静态产物 → nginx 提供
FROM node:20-alpine AS builder
WORKDIR /src
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . ./
# 后端在 docker 网络中通过服务名 server 访问compose 里)
ARG VITE_API_TARGET=http://server:4001
ENV VITE_API_TARGET=$VITE_API_TARGET
RUN npm run build
FROM nginx:alpine
COPY --from=builder /src/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -0,0 +1,45 @@
# aura-web
Aura Agent Platform 前端项目React + Vite + TypeScript
> 由 `rh-agent-platform/web/` 拆分独立而来。后端代码现位于平级项目 `aura-server`
## 本地开发
```bash
npm install
npm run dev # http://localhost:5173
```
默认 `/api` 代理到 `http://localhost:4001`(即 aura-server 的 Go 服务)。
如需切换:
```bash
VITE_API_TARGET=http://localhost:4000 npm run dev
```
## 构建
```bash
npm run build # 产物输出至 dist/
npm run preview # 本地预览
```
## Docker
```bash
cp .env.example .env
docker compose up -d --build
# 浏览器访问 http://localhost:8080
```
## 目录结构
```
src/
├── components/ 公共组件
├── pages/ 路由页面
├── store/ Zustand 状态
├── api.ts REST/SSE 客户端
└── main.tsx 入口
```

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
version: "3.9"
# 独立的前端编排:构建 Vite 产物并由 nginx 提供
# 后端地址通过构建参数 VITE_API_TARGET 注入(默认指向同机 4001
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
VITE_API_TARGET: ${VITE_API_TARGET:-http://host.docker.internal:4001}
image: agent-studio-web:v0.9
container_name: agent-web
restart: unless-stopped
ports:
- "${HTTP_PORT:-8080}:80"

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 智能体管理平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@ -1 +0,0 @@
@AGENTS.md

View File

@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View File

@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +0,0 @@
{
"name": "my-content-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

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

33
nginx.conf Normal file
View File

@ -0,0 +1,33 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 反代 API + SSE 兼容
location /api/ {
proxy_pass http://server:4001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 600s;
chunked_transfer_encoding on;
# SSE 不要被压缩
proxy_set_header Accept-Encoding "identity";
}
# SPA history fallback
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
}

4029
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "agent-platform-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"antd": "^5.21.0",
"axios": "^1.7.7",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.26.2",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
public/default_bot_icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

110
src/App.tsx Normal file
View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Spin } from 'antd';
import Sidebar from './components/Sidebar';
import CommandPalette from './components/CommandPalette';
import AgentList from './pages/AgentList';
import AgentEditor from './pages/AgentEditor';
import ChatPage from './pages/ChatPage';
import LoginPage from './pages/LoginPage';
import MarketplacePage from './pages/MarketplacePage';
import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage';
import LLMProvidersPage from './pages/LLMProvidersPage';
import SharedSessionPage from './pages/SharedSessionPage';
import WorkflowsPage from './pages/WorkflowsPage';
import { useAuth } from './store/auth';
import { AgentAPI } from './api';
function HomeRedirect() {
const navigate = useNavigate();
useEffect(() => {
AgentAPI.list().then(list => {
if (list.length > 0) {
navigate(`/agents/${list[0].id}/chat`, { replace: true });
} else {
navigate('/agents', { replace: true });
}
}).catch(() => {
navigate('/agents', { replace: true });
});
}, [navigate]);
return <div className="flex items-center justify-center h-full"><Spin size="large" /></div>;
}
export default function App() {
const { user, loading, bootstrap } = useAuth();
const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
bootstrap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 全局快捷键 Ctrl/⌘ + K
useEffect(() => {
if (!user) return;
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setPaletteOpen((v) => !v);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [user]);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
const mainContent = (
<Routes>
<Route path="/" element={<HomeRedirect />} />
<Route path="/agents" element={<AgentList />} />
<Route path="/agents/new" element={<AgentEditor />} />
<Route path="/agents/:id" element={<AgentEditor />} />
<Route path="/agents/:id/chat" element={<ChatPage />} />
<Route path="/marketplace" element={<MarketplacePage />} />
<Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/llm-providers" element={<LLMProvidersPage />} />
<Route path="/workflows" element={<WorkflowsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
return (
<>
{/* 公开分享会话页:跳过 AuthGuard */}
{location.pathname.startsWith('/shared/') ? (
<Routes>
<Route path="/shared/:token" element={<SharedSessionPage />} />
</Routes>
) : !user ? (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<LoginPage />} />
</Routes>
) : (
<div className="layout-shell">
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
{!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat') ? (
<Sidebar onOpenPalette={() => setPaletteOpen(true)} />
) : null}
<main className="main">
{mainContent}
</main>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
)}
</>
);
}

735
src/api.ts Normal file
View File

@ -0,0 +1,735 @@
import axios from 'axios';
export const api = axios.create({
baseURL: '/api',
timeout: 90000,
withCredentials: true // 关键:跨域请求带 cookie
});
// 401 拦截:自动跳登录
api.interceptors.response.use(
(r) => r,
(err) => {
if (err?.response?.status === 401 && !location.pathname.startsWith('/login')) {
const next = encodeURIComponent(location.pathname + location.search);
location.href = `/login?next=${next}`;
}
return Promise.reject(err);
}
);
export type KnowledgeStatus = 'pending' | 'indexing' | 'ready' | 'failed';
export interface KnowledgeFile {
id: string;
originalName: string;
filename: string;
size: number;
mime: string;
status: KnowledgeStatus;
error?: string;
chunkCount: number;
createdAt: number;
}
export type SkillType = 'prompt' | 'http' | 'js';
export interface SkillBrief {
id: string;
name: string;
filename: string;
description?: string;
type: SkillType;
enabled: number;
createdAt: number;
}
export interface SkillDetail extends SkillBrief {
content: string;
parameters: string;
handler: string;
config: string;
}
export interface Agent {
id: string;
name: string;
description: string;
avatar: string;
prompt: string;
model: string;
temperature: number;
owner_id?: string | null;
team_id?: string | null;
visibility?: 'private' | 'team' | 'public';
fork_count?: number;
forked_from?: string | null;
created_at: number;
updated_at: number;
knowledge?: KnowledgeFile[];
skills?: SkillBrief[];
_access?: 'owner' | 'team' | 'view' | 'none';
}
export interface RetrievedSnippet {
fileName: string;
chunkIndex: number;
score: number;
preview: string;
}
export interface ToolCallTrace {
name: string;
args: any;
result: any;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
parentId?: string | null;
createdAt: number;
meta?: {
retrieved?: RetrievedSnippet[];
toolCalls?: ToolCallTrace[];
aborted?: boolean;
} | null;
}
export interface BranchInfo {
total: number;
activeIndex: number;
ids: string[];
}
export interface ChatHistoryResp {
messages: ChatMessage[];
branches: Record<string, BranchInfo>;
}
export const AgentAPI = {
list: () => api.get<Agent[]>('/agents').then((r) => r.data),
detail: (id: string) => api.get<Agent>(`/agents/${id}`).then((r) => r.data),
create: (payload: Partial<Agent>) => api.post<Agent>('/agents', payload).then((r) => r.data),
update: (id: string, payload: Partial<Agent>) => api.put<Agent>(`/agents/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/agents/${id}`).then((r) => r.data),
uploadKnowledge: (id: string, files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
return api.post(`/agents/${id}/knowledge`, fd).then((r) => r.data);
},
reindexKnowledge: (agentId: string, fileId: string) =>
api.post(`/agents/${agentId}/knowledge/${fileId}/reindex`).then((r) => r.data),
deleteKnowledge: (agentId: string, fileId: string) =>
api.delete(`/agents/${agentId}/knowledge/${fileId}`).then((r) => r.data),
uploadSkills: (id: string, files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
return api.post(`/agents/${id}/skills`, fd).then((r) => r.data);
},
createSkill: (id: string, payload: { name: string; content: string }) =>
api.post(`/agents/${id}/skills/manual`, payload).then((r) => r.data),
getSkill: (agentId: string, skillId: string) =>
api.get<SkillDetail>(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data),
updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) =>
api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data),
deleteSkill: (agentId: string, skillId: string) =>
api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data)
};
export const ChatAPI = {
history: (agentId: string, sessionId?: string) =>
api
.get<ChatHistoryResp>(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} })
.then((r) => r.data),
send: (
agentId: string,
content: string,
sessionId?: string,
overrides?: ModelOverrides,
attachmentsText?: string,
imageUrls?: string[]
) =>
api
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/chat/${agentId}/messages`, {
content,
sessionId,
overrides,
attachmentsText,
imageUrls
})
.then((r) => r.data),
clear: (agentId: string, sessionId?: string) =>
api
.delete(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} })
.then((r) => r.data),
/** 切换分支:把 user 消息下的某条 assistant 兄弟设为激活 */
switchBranch: (agentId: string, userMsgId: string, branchId: string) =>
api
.post(`/chat/${agentId}/messages/${userMsgId}/switch-branch`, { branchId })
.then((r) => r.data)
};
export interface ChatAttachment {
name: string;
size: number;
mime: string;
text: string;
truncated: boolean;
}
export const ChatAttachmentsAPI = {
upload: (files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
return api
.post<{ files: ChatAttachment[] }>('/chat/attachments', fd)
.then((r) => r.data);
}
};
// ============== MCP ==============
export interface McpServer {
id: string;
name: string;
transport: 'stdio' | 'sse' | 'http';
command: string;
args: string[];
env: Record<string, string>;
url: string;
enabled: boolean;
createdAt: number;
}
export interface McpStatus {
id: string;
name: string;
error?: string;
toolCount: number;
resourceCount: number;
promptCount: number;
capabilities: { tools: boolean; resources: boolean; prompts: boolean };
tools: { name: string; description?: string }[];
resources: { uri: string; name?: string; description?: string; mimeType?: string }[];
prompts: { name: string; description?: string; arguments?: any[] }[];
}
export const McpAPI = {
list: (agentId: string) => api.get<McpServer[]>(`/agents/${agentId}/mcp-servers`).then((r) => r.data),
status: (agentId: string) => api.get<McpStatus[]>(`/agents/${agentId}/mcp-status`).then((r) => r.data),
create: (agentId: string, payload: Partial<McpServer>) =>
api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data),
update: (agentId: string, serverId: string, payload: Partial<McpServer>) =>
api.put(`/agents/${agentId}/mcp-servers/${serverId}`, payload).then((r) => r.data),
remove: (agentId: string, serverId: string) =>
api.delete(`/agents/${agentId}/mcp-servers/${serverId}`).then((r) => r.data),
restart: (agentId: string) => api.post(`/agents/${agentId}/mcp-restart`).then((r) => r.data),
importJSON: (agentId: string, config: any, replace = false) =>
api.post(`/agents/${agentId}/mcp-import`, { config, replace }).then((r) => r.data),
readResource: (agentId: string, serverId: string, uri: string) =>
api.post(`/agents/${agentId}/mcp-read-resource`, { serverId, uri }).then((r) => r.data),
getPrompt: (agentId: string, serverId: string, name: string, args: Record<string, any> = {}) =>
api.post(`/agents/${agentId}/mcp-get-prompt`, { serverId, name, args }).then((r) => r.data)
};
// ============== 会话 ==============
export interface ChatSession {
id: string;
agentId: string;
title: string;
archived?: boolean;
createdAt: number;
updatedAt: number;
messageCount?: number;
lastPreview?: string;
lastAt?: number;
}
export interface SearchHit {
id: string;
sessionId: string;
sessionTitle: string;
sessionArchived: boolean;
role: 'user' | 'assistant';
content: string;
snippet: string;
createdAt: number;
}
export interface SearchResult {
sessions: { id: string; title: string; archived: boolean; created_at: number; updated_at: number }[];
messages: SearchHit[];
}
export const SessionAPI = {
list: (agentId: string, archived: '0' | '1' | 'all' = '0') =>
api.get<ChatSession[]>(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data),
create: (agentId: string, title?: string) =>
api.post<ChatSession>(`/agents/${agentId}/sessions`, { title }).then((r) => r.data),
rename: (agentId: string, sessionId: string, title: string) =>
api.put(`/agents/${agentId}/sessions/${sessionId}`, { title }).then((r) => r.data),
remove: (agentId: string, sessionId: string) =>
api.delete(`/agents/${agentId}/sessions/${sessionId}`).then((r) => r.data),
archive: (agentId: string, sessionId: string, archived: boolean) =>
api.post(`/agents/${agentId}/sessions/${sessionId}/archive`, { archived }).then((r) => r.data),
share: (agentId: string, sessionId: string, ttlHours = 0) =>
api
.post<{ token: string; expiresAt?: number }>(`/agents/${agentId}/sessions/${sessionId}/share`, { ttlHours })
.then((r) => r.data),
revokeShare: (agentId: string, sessionId: string) =>
api.delete(`/agents/${agentId}/sessions/${sessionId}/share`).then((r) => r.data),
search: (agentId: string, q: string, opts: { includeArchived?: boolean; limit?: number } = {}) =>
api
.get<SearchResult>(`/agents/${agentId}/sessions/search`, {
params: {
q,
includeArchived: opts.includeArchived ? '1' : '0',
limit: opts.limit ?? 30
}
})
.then((r) => r.data),
/** 导出会话为文件并触发浏览器下载 */
exportSession: (agentId: string, sessionId: string, format: 'md' | 'json' = 'md') => {
const url = `/api/agents/${agentId}/sessions/${sessionId}/export?format=${format}`;
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
};
// 公开会话访问(不需要登录)
export const SharedAPI = {
get: (token: string) =>
api.get<{
agent: { name: string; description: string };
session: { id: string; title: string; createdAt: number; updatedAt: number };
messages: { id: string; role: 'user' | 'assistant'; content: string; createdAt: number }[];
}>(`/shared/${token}`).then((r) => r.data)
};
// 图床
export interface UploadedImage {
url: string;
name: string;
size: number;
mime: string;
}
export const ImageAPI = {
upload: (files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('file', f));
return api.post<{ files: UploadedImage[] }>('/uploads/image', fd).then((r) => r.data);
}
};
// ============== 全局搜索v0.7 命令面板) ==============
export interface GlobalSearchResult {
agents: { id: string; name: string; description: string; model: string }[];
sessions: { id: string; agentId: string; agentName: string; title: string; updatedAt: number; archived: boolean }[];
messages: {
id: string;
sessionId: string;
agentId: string;
agentName: string;
sessionTitle: string;
role: 'user' | 'assistant';
snippet: string;
createdAt: number;
}[];
}
export const SearchAPI = {
global: (q: string, limit = 8) =>
api.get<GlobalSearchResult>('/search', { params: { q, limit } }).then((r) => r.data)
};
// ============== 鉴权 / 用户 / 团队 / 广场 ==============
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
}
export const AuthAPI = {
me: () => api.get<AuthUser>('/auth/me').then((r) => r.data),
login: (email: string, password: string) =>
api.post<AuthUser>('/auth/login', { email, password }).then((r) => r.data),
register: (payload: { email: string; password: string; name: string; inviteCode?: string }) =>
api.post<AuthUser>('/auth/register', payload).then((r) => r.data),
logout: () => api.post('/auth/logout').then((r) => r.data),
listInvites: () => api.get('/auth/invites').then((r) => r.data),
createInvite: (payload: { email?: string; teamId?: string; role?: string; ttlHours?: number }) =>
api.post('/auth/invites', payload).then((r) => r.data),
deleteInvite: (code: string) => api.delete(`/auth/invites/${code}`).then((r) => r.data)
};
export interface Team {
id: string;
name: string;
ownerId: string;
createdAt: number;
myRole?: 'owner' | 'admin' | 'member';
members?: { id: string; email: string; name: string; role: string; joinedAt: number }[];
agentCount?: number;
}
export const TeamAPI = {
list: () => api.get<Team[]>('/teams').then((r) => r.data),
detail: (id: string) => api.get<Team>(`/teams/${id}`).then((r) => r.data),
create: (name: string) => api.post<Team>('/teams', { name }).then((r) => r.data),
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data),
remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data),
removeMember: (id: string, userId: string) =>
api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data)
};
export interface MarketplaceAgent extends Agent {
ownerName?: string;
fork_count: number;
skillCount: number;
kbCount: number;
}
export const MarketplaceAPI = {
list: () => api.get<MarketplaceAgent[]>('/agents/_/marketplace').then((r) => r.data),
detail: (id: string) => api.get(`/agents/_/marketplace/${id}`).then((r) => r.data),
fork: (id: string) => api.post(`/agents/_/marketplace/${id}/fork`).then((r) => r.data)
};
// ============== Prompt 模板库 (v0.8 P1) ==============
export interface PromptTemplate {
id: string;
ownerId: string;
ownerName?: string;
title: string;
body: string;
category: string;
variables: string;
visibility: 'private' | 'public';
useCount: number;
createdAt: number;
updatedAt: number;
}
export const PromptTemplateAPI = {
list: (opts: { scope?: 'mine' | 'public' | 'all'; q?: string } = {}) =>
api
.get<PromptTemplate[]>('/prompt-templates', {
params: { scope: opts.scope ?? 'all', q: opts.q ?? '' }
})
.then((r) => r.data),
create: (payload: Partial<PromptTemplate>) =>
api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data),
update: (id: string, payload: Partial<PromptTemplate>) =>
api.put(`/prompt-templates/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/prompt-templates/${id}`).then((r) => r.data),
use: (id: string) => api.post(`/prompt-templates/${id}/use`).then((r) => r.data)
};
// ============== 调用统计 (v0.8 P1) ==============
export interface StatsOverview {
agentCount: number;
sessionCount: number;
messageCount: number;
daily: { day: string; total: number; user: number; assistant: number }[];
topAgents: { id: string; name: string; messageCount: number }[];
}
export interface AgentStats {
sessionCount: number;
messageCount: number;
avgMessageLen: number;
assistantWithToolCalls: number;
knowledgeFiles: number;
skillCount: number;
daily: { day: string; count: number }[];
}
export const StatsAPI = {
overview: () => api.get<StatsOverview>('/stats/overview').then((r) => r.data),
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data)
};
// ============== 流式(手写 SSE不用 EventSource因为它不能 POST ==============
// ============== LLM 提供商 (v0.9) ==============
export type LLMKind = 'openai' | 'openai-compatible' | 'anthropic' | 'ollama' | 'tencent-tokenplan';
export interface LLMProvider {
id: string;
name: string;
kind: LLMKind;
baseUrl: string;
apiKeyMasked: string;
hasApiKey: boolean;
models: string[];
defaultModel: string;
enabled: boolean;
isDefault: boolean;
createdAt: number;
updatedAt: number;
}
export const LLMProviderAPI = {
list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data),
create: (payload: Partial<LLMProvider> & { apiKey?: string }) =>
api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data),
update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) =>
api.put(`/llm-providers/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/llm-providers/${id}`).then((r) => r.data),
test: (id: string, model?: string) =>
api.post<{ ok: boolean; reply?: string; error?: string; usage?: any; model?: string }>(
`/llm-providers/${id}/test`,
{ model }
).then((r) => r.data),
setDefault: (id: string) => api.post(`/llm-providers/${id}/default`).then((r) => r.data)
};
export interface StreamEvents {
onMeta?: (data: { userMsgId: string; retrieved: RetrievedSnippet[]; toolsAvailable: number; mcpToolsCount: number }) => void;
onDelta?: (text: string) => void;
onToolCall?: (data: { id: string; name: string; args: any }) => void;
onToolResult?: (data: { id: string; name: string; result: any }) => void;
onDone?: (data: { user: ChatMessage; assistant: ChatMessage }) => void;
onAborted?: (data: { assistant: ChatMessage }) => void;
onError?: (msg: string) => void;
}
export interface ModelOverrides {
model?: string;
temperature?: number;
topP?: number;
maxTokens?: number;
}
export async function streamChat(
agentId: string,
content: string,
h: StreamEvents,
signal?: AbortSignal,
sessionId?: string,
overrides?: ModelOverrides,
attachmentsText?: string,
imageUrls?: string[]
) {
const resp = await fetch(`/api/chat/${agentId}/messages/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, sessionId, overrides, attachmentsText, imageUrls }),
signal,
credentials: 'include'
});
return await consumeSSE(resp, h, signal);
}
/** 重新生成(开新分支);行为同 streamChat */
export async function regenerateMessage(
agentId: string,
assistantMsgId: string,
h: StreamEvents,
signal?: AbortSignal,
overrides?: ModelOverrides,
attachmentsText?: string
) {
const resp = await fetch(`/api/chat/${agentId}/messages/${assistantMsgId}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides, attachmentsText }),
signal,
credentials: 'include'
});
return await consumeSSE(resp, h, signal);
}
async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) {
if (!resp.ok || !resp.body) {
const txt = await resp.text().catch(() => '');
h.onError?.(`HTTP ${resp.status}: ${txt}`);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2);
if (!raw.trim() || raw.startsWith(':')) continue;
let event = 'message';
let dataStr = '';
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data: any;
try {
data = JSON.parse(dataStr);
} catch {
continue;
}
switch (event) {
case 'meta':
h.onMeta?.(data);
break;
case 'delta':
h.onDelta?.(data.content || '');
break;
case 'tool_call':
h.onToolCall?.(data);
break;
case 'tool_result':
h.onToolResult?.(data);
break;
case 'done':
h.onDone?.(data);
break;
case 'aborted':
h.onAborted?.(data);
break;
case 'error':
h.onError?.(data.message || 'stream error');
break;
}
}
}
} catch (e: any) {
if (signal?.aborted || e?.name === 'AbortError') {
// 静默:本地已 abort
return;
}
h.onError?.(e?.message ?? String(e));
}
}
// ============== Workflow (v1.1) ==============
export type WorkflowNodeType = 'agent' | 'skill' | 'http' | 'transform' | 'branch';
export interface WorkflowNode {
id: string;
type: WorkflowNodeType;
name: string;
config: Record<string, any>;
next?: string;
elseNext?: string;
}
export interface WorkflowGraph {
entry: string;
nodes: WorkflowNode[];
variables?: Record<string, any>;
}
export interface Workflow {
id: string;
name: string;
description: string;
graph: WorkflowGraph;
scheduleCron: string;
scheduleEnabled: boolean;
enabled: boolean;
lastRunAt: number;
runCount: number;
createdAt: number;
updatedAt: number;
}
export interface WorkflowRun {
id: string;
workflowId: string;
trigger: 'manual' | 'cron' | 'api';
status: 'running' | 'success' | 'failed' | 'aborted';
error: string;
startedAt: number;
finishedAt: number;
durationMs: number;
}
export interface WorkflowRunStep {
id: string;
nodeId: string;
nodeType: string;
stepIndex: number;
status: 'running' | 'success' | 'failed' | 'skipped';
input: any;
output: any;
error: string;
startedAt: number;
finishedAt: number;
durationMs: number;
}
export interface WorkflowRunDetail extends WorkflowRun {
input: any;
output: any;
steps: WorkflowRunStep[];
}
export const WorkflowAPI = {
list: () => api.get<Workflow[]>('/workflows').then((r) => r.data),
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
create: (payload: Partial<Workflow>) =>
api.post<{ id: string }>('/workflows', payload).then((r) => r.data),
update: (id: string, payload: Partial<Workflow>) =>
api.put(`/workflows/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
run: (id: string, input?: Record<string, any>) =>
api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data),
listRuns: (id: string, limit = 30) =>
api.get<WorkflowRun[]>(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data),
getRun: (runId: string) =>
api.get<WorkflowRunDetail>(`/workflows/runs/${runId}`).then((r) => r.data)
};
// SSE 流式执行 workflow用 EventSourcecookie 由浏览器自动带)
export function streamWorkflowRun(
workflowId: string,
input: Record<string, any> | undefined,
handlers: {
onReady?: (data: any) => void;
onStepStart?: (data: any) => void;
onStepFinish?: (data: any) => void;
onRunFinish?: (data: any) => void;
onError?: (msg: string) => void;
}
): () => void {
const qs = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : '';
const url = `/api/workflows/${workflowId}/run-stream${qs}`;
const es = new EventSource(url, { withCredentials: true } as any);
es.addEventListener('ready', (e: any) => handlers.onReady?.(JSON.parse(e.data)));
es.addEventListener('step_start', (e: any) => handlers.onStepStart?.(JSON.parse(e.data)));
es.addEventListener('step_finish', (e: any) => handlers.onStepFinish?.(JSON.parse(e.data)));
es.addEventListener('run_finish', (e: any) => {
handlers.onRunFinish?.(JSON.parse(e.data));
es.close();
});
es.addEventListener('error', () => {
if (es.readyState === EventSource.CLOSED) return;
handlers.onError?.('stream error');
es.close();
});
return () => es.close();
}

View File

@ -0,0 +1,197 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Input, Space, App as AntApp, Empty, Spin } from 'antd';
import ReactMarkdown from 'react-markdown';
import {
Agent,
ChatMessage,
streamChat,
RetrievedSnippet,
ToolCallTrace
} from '../api';
interface Props {
agent: Agent;
agentId?: string;
}
interface StreamingState {
active: boolean;
text: string;
retrieved: RetrievedSnippet[];
toolCalls: ToolCallTrace[];
}
export default function ChatPreview({ agent, agentId }: Props) {
const { message: msg } = AntApp.useApp();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [streaming, setStreaming] = useState<StreamingState>({
active: false,
text: '',
retrieved: [],
toolCalls: []
});
const bodyRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const scrollBottom = () => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' });
});
};
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const handleSend = async () => {
const text = input.trim();
if (!text || !agentId || sending) return;
setInput('');
setSending(true);
const tempUser: ChatMessage = {
id: 'tmp-' + Date.now(),
role: 'user',
content: text,
createdAt: Date.now()
};
setMessages((m) => [...m, tempUser]);
setStreaming({ active: true, text: '', retrieved: [], toolCalls: [] });
scrollBottom();
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
await streamChat(
agentId,
text,
{
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
onDelta: (chunk) =>
setStreaming((s) => {
const next = { ...s, text: s.text + chunk };
scrollBottom();
return next;
}),
onToolCall: (data) =>
setStreaming((s) => ({
...s,
toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }]
})),
onToolResult: (data) =>
setStreaming((s) => {
const list = [...s.toolCalls];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].name === data.name && (list[i].result as any)?.pending) {
list[i] = { ...list[i], result: data.result };
break;
}
}
return { ...s, toolCalls: list };
}),
onDone: (data) => {
setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), data.user, data.assistant]);
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
scrollBottom();
},
onError: (errMsg) => {
msg.error('预览失败:' + errMsg);
setMessages((m) => m.filter((x) => x.id !== tempUser.id));
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
}
},
ctrl.signal
);
} catch (e: any) {
if (e?.name !== 'AbortError') {
msg.error('请求失败:' + (e?.message ?? e));
}
setMessages((m) => m.filter((x) => x.id !== tempUser.id));
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
} finally {
setSending(false);
}
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
return (
<div className="flex flex-col h-full bg-gray-50 border-l">
<div className="p-4 border-b bg-white flex items-center gap-3">
<div
className="w-8 h-8 rounded-full text-white flex items-center justify-center font-bold overflow-hidden"
style={{ background: agent.avatar || '#0891b2' }}
>
{isImageUrl(agent.avatar) ? (
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(agent?.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div className="font-semibold text-gray-800"></div>
</div>
<div ref={bodyRef} className="flex-1 overflow-auto p-4 space-y-4">
{messages.length === 0 && !streaming.active ? (
<Empty description="在这里测试你的智能体" style={{ marginTop: 100 }} />
) : (
<div className="flex flex-col gap-4">
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`bubble ${m.role === 'user' ? 'user' : 'assistant'}`}>
<ReactMarkdown>{m.content}</ReactMarkdown>
</div>
</div>
))}
{streaming.active && (
<div className="flex justify-start">
<div className="bubble assistant">
{streaming.text ? (
<ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown>
) : (
<span className="text-gray-400">...</span>
)}
</div>
</div>
)}
</div>
)}
</div>
<div className="p-4 bg-white border-t">
<div className="flex gap-2">
<Input.TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入消息测试..."
autoSize={{ minRows: 1, maxRows: 4 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
disabled={sending || !agentId}
className="rounded-lg"
/>
<Button
type="primary"
onClick={handleSend}
disabled={!agentId}
loading={sending}
style={{ backgroundColor: '#0891b2', height: 'auto' }}
>
</Button>
</div>
{!agentId && (
<div className="text-xs text-gray-400 mt-1"></div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,244 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Modal, Input, Spin, Empty, Tag } from 'antd';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { SearchAPI, GlobalSearchResult } from '../api';
interface Props {
open: boolean;
onClose: () => void;
}
type FlatItem =
| { kind: 'agent'; id: string; title: string; subtitle: string; route: string }
| { kind: 'session'; id: string; agentId: string; title: string; subtitle: string; archived: boolean; route: string }
| { kind: 'message'; id: string; agentId: string; sessionId: string; title: string; snippet: string; role: 'user' | 'assistant'; createdAt: number; route: string };
export default function CommandPalette({ open, onClose }: Props) {
const [q, setQ] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<GlobalSearchResult | null>(null);
const [active, setActive] = useState(0);
const navigate = useNavigate();
const inputRef = useRef<any>(null);
// 重置
useEffect(() => {
if (open) {
setQ('');
setResult(null);
setActive(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// 防抖搜索
useEffect(() => {
const query = q.trim();
if (!query) {
setResult(null);
return;
}
const t = setTimeout(async () => {
setLoading(true);
try {
const r = await SearchAPI.global(query, 6);
setResult(r);
setActive(0);
} catch {
setResult({ agents: [], sessions: [], messages: [] });
} finally {
setLoading(false);
}
}, 200);
return () => clearTimeout(t);
}, [q]);
const flat = useMemo<FlatItem[]>(() => {
if (!result) return [];
const list: FlatItem[] = [];
for (const a of result.agents) {
list.push({
kind: 'agent',
id: a.id,
title: a.name,
subtitle: a.description || a.model,
route: `/agents/${a.id}/chat`
});
}
for (const s of result.sessions) {
list.push({
kind: 'session',
id: s.id,
agentId: s.agentId,
title: s.title,
subtitle: `${s.agentName} · ${dayjs(s.updatedAt).format('MM-DD HH:mm')}`,
archived: s.archived,
route: `/agents/${s.agentId}/chat?session=${s.id}`
});
}
for (const m of result.messages) {
list.push({
kind: 'message',
id: m.id,
agentId: m.agentId,
sessionId: m.sessionId,
title: `${m.agentName} / ${m.sessionTitle}`,
snippet: m.snippet,
role: m.role,
createdAt: m.createdAt,
route: `/agents/${m.agentId}/chat?session=${m.sessionId}&msg=${m.id}`
});
}
return list;
}, [result]);
const handlePick = (it: FlatItem) => {
navigate(it.route);
onClose();
};
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => Math.min(flat.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(0, i - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
const it = flat[active];
if (it) handlePick(it);
} else if (e.key === 'Escape') {
onClose();
}
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={640}
closable={false}
destroyOnHidden
styles={{ body: { padding: 0 }, content: { padding: 0, overflow: 'hidden' } }}
style={{ top: 80 }}
>
<div style={{ padding: 12, borderBottom: '1px solid #f0f0f0' }}>
<Input
ref={inputRef}
size="large"
variant="borderless"
placeholder="搜索智能体、会话、消息… (↑↓ 切换 · Enter 跳转 · Esc 关闭)"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
prefix={<span style={{ color: '#9ca3af' }}>K</span>}
/>
</div>
<div style={{ maxHeight: 440, overflow: 'auto' }}>
{loading && (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="small" />
</div>
)}
{!loading && q.trim() && flat.length === 0 && (
<Empty
description={`未找到与 "${q.trim()}" 相关的内容`}
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: 32 }}
/>
)}
{!loading && !q.trim() && (
<div style={{ padding: 24, color: '#9ca3af', fontSize: 13 }}>
<div style={{ marginBottom: 8 }}>💡 </div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.8 }}>
<li><kbd>Ctrl/ + K</kbd> </li>
<li><kbd> </kbd> <kbd>Enter</kbd> </li>
<li> / / </li>
</ul>
</div>
)}
{!loading && flat.length > 0 && (
<div>
{(() => {
const groups: { label: string; items: FlatItem[] }[] = [];
const agents = flat.filter((x) => x.kind === 'agent');
const sessions = flat.filter((x) => x.kind === 'session');
const messages = flat.filter((x) => x.kind === 'message');
if (agents.length) groups.push({ label: '🤖 智能体', items: agents });
if (sessions.length) groups.push({ label: '💬 会话', items: sessions });
if (messages.length) groups.push({ label: '📝 消息', items: messages });
let cursor = 0;
return groups.map((g) => (
<div key={g.label}>
<div style={{ padding: '6px 14px', fontSize: 11, color: '#9ca3af', background: '#fafafa' }}>
{g.label}
</div>
{g.items.map((it) => {
const idx = cursor++;
const isActive = idx === active;
return (
<div
key={it.kind + ':' + it.id}
onMouseEnter={() => setActive(idx)}
onClick={() => handlePick(it)}
style={{
padding: '8px 14px',
cursor: 'pointer',
background: isActive ? '#eef2ff' : 'transparent',
borderLeft: isActive ? '3px solid #6366f1' : '3px solid transparent'
}}
>
{it.kind === 'agent' && (
<>
<div style={{ fontSize: 13, fontWeight: 500, color: '#1f2330' }}>{it.title}</div>
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>{it.subtitle}</div>
</>
)}
{it.kind === 'session' && (
<>
<div style={{ fontSize: 13, color: '#1f2330' }}>
💬 {it.title}
{it.archived && <Tag style={{ marginLeft: 6 }}></Tag>}
</div>
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>{it.subtitle}</div>
</>
)}
{it.kind === 'message' && (
<>
<div style={{ fontSize: 11, color: '#6b7280', marginBottom: 2 }}>
<span
style={{
display: 'inline-block',
background: it.role === 'user' ? '#e0e7ff' : '#d1fae5',
color: it.role === 'user' ? '#4338ca' : '#065f46',
padding: '0 4px',
borderRadius: 3,
marginRight: 6,
fontSize: 10
}}
>
{it.role === 'user' ? '我' : 'AI'}
</span>
{it.title} · {dayjs(it.createdAt).format('MM-DD HH:mm')}
</div>
<div
style={{ fontSize: 12, color: '#374151', lineHeight: 1.4 }}
dangerouslySetInnerHTML={{ __html: it.snippet }}
/>
</>
)}
</div>
);
})}
</div>
));
})()}
</div>
)}
</div>
</Modal>
);
}

385
src/components/McpPanel.tsx Normal file
View File

@ -0,0 +1,385 @@
import { useEffect, useState } from 'react';
import { Button, Card, List, Modal, Form, Input, Select, Tag, Space, Popconfirm, App as AntApp, Tooltip, Alert } from 'antd';
import { McpAPI, McpServer, McpStatus } from '../api';
interface Props {
agentId: string;
}
const PRESETS: { label: string; description: string; config: any }[] = [
{
label: 'filesystem (官方 NPM)',
description: '本地文件系统操作(读、列目录)。请把最后一个参数改成你要暴露给智能体的目录绝对路径。',
config: {
mcpServers: {
filesystem: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/your/dir']
}
}
}
},
{
label: 'fetch (官方 NPM)',
description: '抓取网页/URL 内容',
config: {
mcpServers: {
fetch: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-fetch'] }
}
}
},
{
label: 'sequential-thinking (官方 NPM)',
description: '辅助思维链推理',
config: {
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking']
}
}
}
}
];
export default function McpPanel({ agentId }: Props) {
const { message } = AntApp.useApp();
const [servers, setServers] = useState<McpServer[]>([]);
const [statusList, setStatusList] = useState<McpStatus[]>([]);
const [statusLoading, setStatusLoading] = useState(false);
const [editing, setEditing] = useState<McpServer | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importJson, setImportJson] = useState('');
const refresh = async () => {
const list = await McpAPI.list(agentId);
setServers(list);
};
const refreshStatus = async () => {
setStatusLoading(true);
try {
const s = await McpAPI.status(agentId);
setStatusList(s);
} catch (e: any) {
message.error('状态获取失败:' + (e?.message ?? e));
} finally {
setStatusLoading(false);
}
};
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentId]);
const handleSave = async (values: any) => {
try {
// args 字符串 → 数组
let args: string[] = [];
if (typeof values.argsText === 'string') {
args = values.argsText
.split(/\r?\n/)
.map((s: string) => s.trim())
.filter(Boolean);
}
let env: Record<string, string> = {};
if (values.envText) {
try {
env = JSON.parse(values.envText);
} catch {
message.error('env 必须是合法 JSON 对象');
return;
}
}
const payload = {
name: values.name,
transport: values.transport,
command: values.command || '',
args,
env,
url: values.url || ''
};
if (editing) {
await McpAPI.update(agentId, editing.id, payload);
message.success('已更新');
} else {
await McpAPI.create(agentId, payload);
message.success('已创建');
}
setEditing(null);
setCreateOpen(false);
refresh();
} catch (e: any) {
message.error('保存失败:' + (e?.message ?? e));
}
};
const handleImport = async () => {
let parsed: any;
try {
parsed = JSON.parse(importJson);
} catch {
message.error('JSON 解析失败');
return;
}
try {
const r = await McpAPI.importJSON(agentId, parsed, false);
message.success(`已导入 ${r.imported} 个 MCP Server`);
setImportOpen(false);
setImportJson('');
refresh();
} catch (e: any) {
message.error('导入失败:' + (e?.message ?? e));
}
};
return (
<Card
title={
<Space>
<span>🔌 MCP Servers</span>
<Tag>{servers.length} </Tag>
</Space>
}
extra={
<Space>
<Button onClick={refreshStatus} loading={statusLoading}>
🔄
</Button>
<Button onClick={() => setImportOpen(true)}>📥 JSON</Button>
<Button
type="primary"
onClick={() => {
setEditing(null);
setCreateOpen(true);
}}
>
</Button>
</Space>
}
>
<Alert
style={{ marginBottom: 12 }}
type="info"
showIcon
message="MCP (Model Context Protocol) 让智能体能调用外部工具。本机首次连接 stdio 类型会启动子进程,可能需要数秒。"
/>
<List
dataSource={servers}
locale={{ emptyText: '尚未配置 MCP Server' }}
renderItem={(s) => {
const status = statusList.find((x) => x.id === s.id);
return (
<List.Item
actions={[
<Button
key="edit"
size="small"
onClick={() => {
setEditing(s);
setCreateOpen(true);
}}
>
</Button>,
<Popconfirm
key="del"
title="确认删除该 MCP Server"
onConfirm={async () => {
await McpAPI.remove(agentId, s.id);
message.success('已删除');
refresh();
}}
>
<Button danger size="small">
</Button>
</Popconfirm>
]}
>
<List.Item.Meta
title={
<Space>
<Tag color={s.transport === 'stdio' ? 'blue' : 'green'}>{s.transport}</Tag>
<span>{s.name}</span>
{!s.enabled && <Tag></Tag>}
{status?.error && (
<Tooltip title={status.error}>
<Tag color="error"></Tag>
</Tooltip>
)}
{status && !status.error && (
<Tag color="success">{status.toolCount} </Tag>
)}
</Space>
}
description={
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<code style={{ fontSize: 12, color: '#6b7280' }}>
{s.transport === 'stdio'
? `${s.command} ${s.args.join(' ')}`
: s.url}
</code>
{status?.tools && status.tools.length > 0 && (
<Space wrap size={4}>
{status.tools.slice(0, 8).map((t) => (
<Tooltip key={t.name} title={t.description}>
<Tag color="purple" style={{ fontSize: 11 }}>
{t.name}
</Tag>
</Tooltip>
))}
{status.tools.length > 8 && <Tag>+{status.tools.length - 8}</Tag>}
</Space>
)}
</Space>
}
/>
</List.Item>
);
}}
/>
<Modal
open={createOpen}
title={editing ? '编辑 MCP Server' : '新增 MCP Server'}
width={680}
onCancel={() => {
setCreateOpen(false);
setEditing(null);
}}
footer={null}
destroyOnHidden
>
<McpForm
initial={editing}
onSubmit={handleSave}
onCancel={() => {
setCreateOpen(false);
setEditing(null);
}}
/>
</Modal>
<Modal
open={importOpen}
title="📥 导入 mcpServers JSON"
width={760}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="导入"
cancelText="取消"
>
<Alert
type="info"
showIcon
message="兼容 Claude Desktop / Cursor 的 mcpServers 配置格式。"
style={{ marginBottom: 12 }}
/>
<Space style={{ marginBottom: 8 }} wrap>
{PRESETS.map((p) => (
<Tooltip key={p.label} title={p.description}>
<Button size="small" onClick={() => setImportJson(JSON.stringify(p.config, null, 2))}>
{p.label}
</Button>
</Tooltip>
))}
</Space>
<Input.TextArea
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder={`{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
}
}`}
autoSize={{ minRows: 12, maxRows: 20 }}
style={{ fontFamily: 'Consolas, Menlo, monospace', fontSize: 13 }}
/>
</Modal>
</Card>
);
}
function McpForm({
initial,
onSubmit,
onCancel
}: {
initial: McpServer | null;
onSubmit: (values: any) => void;
onCancel: () => void;
}) {
const [form] = Form.useForm();
const [transport, setTransport] = useState<'stdio' | 'sse' | 'http'>(initial?.transport ?? 'stdio');
useEffect(() => {
form.setFieldsValue({
name: initial?.name ?? '',
transport: initial?.transport ?? 'stdio',
command: initial?.command ?? '',
argsText: (initial?.args ?? []).join('\n'),
envText: initial?.env ? JSON.stringify(initial.env, null, 2) : '',
url: initial?.url ?? ''
});
setTransport(initial?.transport ?? 'stdio');
}, [initial, form]);
return (
<Form form={form} layout="vertical" onFinish={onSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="例如 filesystem / playwright" />
</Form.Item>
<Form.Item name="transport" label="Transport" rules={[{ required: true }]}>
<Select
options={[
{ value: 'stdio', label: 'stdio (本机进程)' },
{ value: 'sse', label: 'SSE (远程)' },
{ value: 'http', label: 'HTTP (Streamable)' }
]}
onChange={(v) => setTransport(v as any)}
/>
</Form.Item>
{transport === 'stdio' ? (
<>
<Form.Item name="command" label="Command" rules={[{ required: true }]} extra="如 npx / node / python">
<Input placeholder="npx" />
</Form.Item>
<Form.Item name="argsText" label="Args (每行一个)">
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'-y\n@modelcontextprotocol/server-filesystem\n/path/to/dir'}
style={{ fontFamily: 'Consolas, monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item name="envText" label="Env (JSON 对象)">
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
placeholder={'{ "API_KEY": "xxx" }'}
style={{ fontFamily: 'Consolas, monospace', fontSize: 13 }}
/>
</Form.Item>
</>
) : (
<Form.Item name="url" label="URL" rules={[{ required: true }]}>
<Input placeholder="https://example.com/mcp" />
</Form.Item>
)}
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form>
);
}

View File

@ -0,0 +1,305 @@
import { useEffect, useState } from 'react';
import { Drawer, Tabs, Button, List, Space, Tag, App as AntApp, Empty, Modal, Form, Input, Spin } from 'antd';
import { McpAPI, McpStatus } from '../api';
interface Props {
agentId: string;
open: boolean;
onClose: () => void;
/** 当用户选中要"使用"某个 Resource 或 Prompt 时,回调把内容塞回输入框 */
onUse: (text: string) => void;
}
export default function McpResourcesDrawer({ agentId, open, onClose, onUse }: Props) {
const { message } = AntApp.useApp();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<McpStatus[]>([]);
const [contentModal, setContentModal] = useState<{ title: string; content: string } | null>(null);
const [promptModal, setPromptModal] = useState<{ server: McpStatus; prompt: any } | null>(null);
const [promptArgs, setPromptArgs] = useState<Record<string, any>>({});
const load = async () => {
setLoading(true);
try {
const s = await McpAPI.status(agentId);
setStatus(s);
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, agentId]);
const handleReadResource = async (serverId: string, uri: string, name: string) => {
try {
const r = await McpAPI.readResource(agentId, serverId, uri);
// 把 contents 拼成 markdown 文本
const text = (r.contents ?? [])
.map((c: any) => {
if (c.text) return c.text;
if (c.blob) return `[binary blob: ${c.mimeType}, ${c.blob.length} bytes]`;
return '';
})
.filter(Boolean)
.join('\n\n');
setContentModal({ title: name || uri, content: text || '(空)' });
} catch (e: any) {
message.error('读取失败:' + (e?.message ?? e));
}
};
const handleGetPrompt = async () => {
if (!promptModal) return;
try {
const r = await McpAPI.getPrompt(agentId, promptModal.server.id, promptModal.prompt.name, promptArgs);
// prompt 的 messages 转成可用文本
const text = (r.messages ?? [])
.map((m: any) => {
if (typeof m.content === 'string') return m.content;
if (m.content?.text) return m.content.text;
if (Array.isArray(m.content)) return m.content.map((c: any) => c.text || '').filter(Boolean).join('\n');
return '';
})
.filter(Boolean)
.join('\n\n');
onUse(text);
message.success('已填充到输入框');
setPromptModal(null);
setPromptArgs({});
onClose();
} catch (e: any) {
message.error('获取失败:' + (e?.message ?? e));
}
};
return (
<>
<Drawer
open={open}
onClose={onClose}
title="🔌 MCP 资源 & 提示词"
width={680}
extra={
<Button onClick={load} loading={loading}>
🔄
</Button>
}
>
{loading ? (
<Spin />
) : status.length === 0 ? (
<Empty description="此智能体未配置 MCP Server" />
) : (
<Tabs
items={status.map((srv) => ({
key: srv.id,
label: (
<Space>
<span>{srv.name}</span>
{srv.error ? (
<Tag color="error"></Tag>
) : (
<>
<Tag>{srv.resourceCount} </Tag>
<Tag>{srv.promptCount} </Tag>
</>
)}
</Space>
),
children: srv.error ? (
<Empty description={`连接失败:${srv.error}`} />
) : (
<Tabs
size="small"
items={[
{
key: 'res',
label: `📄 Resources (${srv.resourceCount})`,
children: srv.resources.length === 0 ? (
<Empty description="该 Server 不提供 Resources" />
) : (
<List
dataSource={srv.resources}
renderItem={(r) => (
<List.Item
actions={[
<Button
key="read"
size="small"
onClick={() => handleReadResource(srv.id, r.uri, r.name || r.uri)}
>
</Button>,
<Button
key="use"
type="primary"
size="small"
onClick={async () => {
const data = await McpAPI.readResource(agentId, srv.id, r.uri);
const text = (data.contents ?? [])
.map((c: any) => c.text || '')
.filter(Boolean)
.join('\n\n');
onUse(`请基于以下资料回答:\n\n---\n${text}\n---\n\n`);
onClose();
}}
>
使
</Button>
]}
>
<List.Item.Meta
title={
<Space>
<span>{r.name || r.uri.split('/').pop()}</span>
{r.mimeType && <Tag color="geekblue">{r.mimeType}</Tag>}
</Space>
}
description={
<div>
<code style={{ fontSize: 11 }}>{r.uri}</code>
{r.description && <div style={{ marginTop: 4 }}>{r.description}</div>}
</div>
}
/>
</List.Item>
)}
/>
)
},
{
key: 'pr',
label: `💡 Prompts (${srv.promptCount})`,
children: srv.prompts.length === 0 ? (
<Empty description="该 Server 不提供 Prompts" />
) : (
<List
dataSource={srv.prompts}
renderItem={(p) => (
<List.Item
actions={[
<Button
key="use"
type="primary"
size="small"
onClick={() => {
setPromptModal({ server: srv, prompt: p });
setPromptArgs({});
}}
>
使
</Button>
]}
>
<List.Item.Meta
title={p.name}
description={
<div>
{p.description}
{p.arguments && p.arguments.length > 0 && (
<div style={{ marginTop: 4 }}>
<Space size={4} wrap>
{p.arguments.map((a: any) => (
<Tag key={a.name} color={a.required ? 'red' : 'default'}>
{a.name}
{a.required ? ' *' : ''}
</Tag>
))}
</Space>
</div>
)}
</div>
}
/>
</List.Item>
)}
/>
)
}
]}
/>
)
}))}
/>
)}
</Drawer>
<Modal
open={!!contentModal}
title={contentModal?.title}
width={760}
onCancel={() => setContentModal(null)}
footer={[
<Button key="close" onClick={() => setContentModal(null)}>
</Button>,
<Button
key="use"
type="primary"
onClick={() => {
if (contentModal) {
onUse(`请基于以下资料回答:\n\n---\n${contentModal.content}\n---\n\n`);
setContentModal(null);
onClose();
}
}}
>
</Button>
]}
>
<pre
style={{
background: '#f6f8fa',
padding: 12,
borderRadius: 6,
maxHeight: '60vh',
overflow: 'auto',
whiteSpace: 'pre-wrap',
fontSize: 12
}}
>
{contentModal?.content}
</pre>
</Modal>
<Modal
open={!!promptModal}
title={`💡 使用 Prompt: ${promptModal?.prompt.name}`}
onCancel={() => {
setPromptModal(null);
setPromptArgs({});
}}
onOk={handleGetPrompt}
okText="获取并填充"
cancelText="取消"
>
<div style={{ color: '#6b7280', marginBottom: 12 }}>{promptModal?.prompt.description}</div>
<Form layout="vertical">
{(promptModal?.prompt.arguments ?? []).map((arg: any) => (
<Form.Item
key={arg.name}
label={
<Space>
<span>{arg.name}</span>
{arg.required && <Tag color="red"></Tag>}
</Space>
}
extra={arg.description}
>
<Input
value={promptArgs[arg.name] || ''}
onChange={(e) => setPromptArgs((p) => ({ ...p, [arg.name]: e.target.value }))}
/>
</Form.Item>
))}
</Form>
</Modal>
</>
);
}

View File

@ -0,0 +1,393 @@
import { useEffect, useState } from 'react';
import { Button, List, Popconfirm, Input, App as AntApp, Tooltip, Empty, Segmented, Tag, Spin, Dropdown, Modal } from 'antd';
import dayjs from 'dayjs';
import { ChatSession, SearchHit, SessionAPI } from '../api';
interface Props {
agentId: string;
activeSessionId: string | null;
onChange: (sessionId: string, options?: { highlightMessageId?: string }) => void;
refreshTick?: number;
theme?: 'light' | 'dark';
}
export default function SessionSidebar({ agentId, activeSessionId, onChange, refreshTick, theme = 'light' }: Props) {
const { message } = AntApp.useApp();
const [tab, setTab] = useState<'active' | 'archived'>('active');
const [active, setActive] = useState<ChatSession[]>([]);
const [archived, setArchived] = useState<ChatSession[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
// 搜索
const [searchQ, setSearchQ] = useState('');
const [searching, setSearching] = useState(false);
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searchSessionTitles, setSearchSessionTitles] = useState<{ id: string; title: string; archived: boolean }[]>([]);
const load = async () => {
const [a, ar] = await Promise.all([SessionAPI.list(agentId, '0'), SessionAPI.list(agentId, '1')]);
setActive(a);
setArchived(ar);
// 自动选中
if (!activeSessionId) {
if (a.length === 0) {
const created = await SessionAPI.create(agentId);
setActive([created]);
onChange(created.id);
} else {
onChange(a[0].id);
}
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentId, refreshTick]);
const isDark = theme === 'dark';
// Theme-aware colors
const c = {
text: isDark ? '#c8c8d8' : '#1f2330',
textDim: isDark ? '#8b8ba0' : '#9ca3af',
textActive: isDark ? '#e0e0f0' : '#4338ca',
bgActive: isDark ? 'rgba(99,102,241,0.12)' : '#eef2ff',
borderActive: isDark ? 'rgba(99,102,241,0.3)' : '#c7d2fe',
bgHover: isDark ? 'rgba(255,255,255,0.04)' : '#f9fafb',
bgSearch: isDark ? 'rgba(255,255,255,0.04)' : '#f9fafb',
bgTransparent: 'transparent',
inputBg: isDark ? 'rgba(255,255,255,0.08)' : '#fff',
border: isDark ? 'rgba(255,255,255,0.08)' : '#f0f1f5',
dangerText: isDark ? '#f87171' : '#ef4444',
primaryBtn: isDark ? '#6366f1' : undefined,
};
const list = tab === 'active' ? active : archived;
// 防抖搜索
useEffect(() => {
const q = searchQ.trim();
if (!q) {
setSearchHits([]);
setSearchSessionTitles([]);
return;
}
const t = setTimeout(async () => {
setSearching(true);
try {
const r = await SessionAPI.search(agentId, q, { includeArchived: true, limit: 30 });
setSearchHits(r.messages || []);
setSearchSessionTitles(r.sessions || []);
} catch (e: any) {
message.error('搜索失败:' + (e?.message ?? e));
} finally {
setSearching(false);
}
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQ]);
const handleNew = async () => {
const created = await SessionAPI.create(agentId);
setActive((l) => [created, ...l]);
setTab('active');
onChange(created.id);
};
const handleDelete = async (id: string) => {
await SessionAPI.remove(agentId, id);
const nextActive = active.filter((s) => s.id !== id);
const nextArchived = archived.filter((s) => s.id !== id);
setActive(nextActive);
setArchived(nextArchived);
if (id === activeSessionId) {
const fallback = nextActive[0] || nextArchived[0];
if (fallback) onChange(fallback.id);
else {
const c = await SessionAPI.create(agentId);
setActive([c]);
onChange(c.id);
}
}
};
const handleArchive = async (s: ChatSession, archive: boolean) => {
await SessionAPI.archive(agentId, s.id, archive);
message.success(archive ? '已归档' : '已恢复');
load();
};
const handleShare = async (s: ChatSession) => {
try {
const r = await SessionAPI.share(agentId, s.id, 0);
const url = `${location.origin}/shared/${r.token}`;
try {
await navigator.clipboard.writeText(url);
message.success('🔗 公开分享链接已复制到剪贴板');
} catch {
Modal.info({
title: '公开分享链接',
content: (
<div>
<p>访</p>
<Input.TextArea value={url} readOnly autoSize />
</div>
)
});
}
} catch (e: any) {
message.error('生成链接失败:' + (e?.message ?? e));
}
};
const handleRename = async (id: string) => {
if (!editingTitle.trim()) {
setEditingId(null);
return;
}
await SessionAPI.rename(agentId, id, editingTitle.trim());
setActive((l) => l.map((s) => (s.id === id ? { ...s, title: editingTitle.trim() } : s)));
setArchived((l) => l.map((s) => (s.id === id ? { ...s, title: editingTitle.trim() } : s)));
setEditingId(null);
message.success('已重命名');
};
// 渲染单个会话
const renderSession = (s: ChatSession) => {
const isActive = s.id === activeSessionId;
const editing = editingId === s.id;
return (
<List.Item
key={s.id}
onClick={() => (editing ? null : onChange(s.id))}
style={{
cursor: 'pointer',
padding: '8px 10px',
background: isActive ? c.bgActive : c.bgTransparent,
borderRadius: 6,
border: isActive ? `1px solid ${c.borderActive}` : '1px solid transparent',
marginBottom: 4
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<Input
size="small"
autoFocus
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onPressEnter={() => handleRename(s.id)}
onBlur={() => handleRename(s.id)}
/>
) : (
<>
<div
style={{
fontSize: 13,
fontWeight: isActive ? 600 : 500,
color: isActive ? c.textActive : c.text,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
💬 {s.title}
</div>
<div
style={{
fontSize: 11,
color: c.textDim,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: 2
}}
>
{s.lastPreview || '无消息'} · {dayjs(s.lastAt || s.updatedAt).format('MM-DD HH:mm')}
</div>
</>
)}
</div>
{!editing && (
<div style={{ display: 'flex', gap: 2, marginLeft: 6 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="重命名">
<Button
type="text"
size="small"
onClick={() => {
setEditingId(s.id);
setEditingTitle(s.title);
}}
>
</Button>
</Tooltip>
<Tooltip title={tab === 'active' ? '归档' : '恢复'}>
<Button type="text" size="small" onClick={() => handleArchive(s, tab === 'active')}>
{tab === 'active' ? '📦' : '↩️'}
</Button>
</Tooltip>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'md', label: '⬇️ Markdown (.md)' },
{ key: 'json', label: '⬇️ JSON (.json)' }
],
onClick: ({ key }) => {
SessionAPI.exportSession(agentId, s.id, key as 'md' | 'json');
message.success('已开始下载');
}
}}
>
<Tooltip title="导出会话">
<Button type="text" size="small">
</Button>
</Tooltip>
</Dropdown>
<Tooltip title="分享公开链接(任何人都可只读访问)">
<Button type="text" size="small" onClick={() => handleShare(s)}>
🔗
</Button>
</Tooltip>
<Popconfirm title="删除此会话(不可恢复)?" onConfirm={() => handleDelete(s.id)} okText="删除" cancelText="取消">
<Button type="text" size="small" danger>
🗑
</Button>
</Popconfirm>
</div>
)}
</List.Item>
);
};
const inSearchMode = searchQ.trim().length > 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Button type="primary" onClick={handleNew} style={{ marginBottom: 8, background: c.primaryBtn }} block>
</Button>
<Input.Search
placeholder="搜索会话与消息…"
allowClear
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
style={{ marginBottom: 8 }}
styles={isDark ? { input: { background: c.inputBg, borderColor: c.border, color: c.text } } : undefined}
/>
{inSearchMode ? (
<div style={{ flex: 1, overflow: 'auto' }}>
{searching ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Spin size="small" />
</div>
) : searchHits.length === 0 && searchSessionTitles.length === 0 ? (
<Empty description="无匹配结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<>
{searchSessionTitles.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, color: c.textDim, padding: '4px 8px' }}>
📁 ({searchSessionTitles.length})
</div>
{searchSessionTitles.map((s) => (
<div
key={s.id}
onClick={() => onChange(s.id)}
style={{
cursor: 'pointer',
padding: '6px 10px',
borderRadius: 6,
marginBottom: 2,
background: activeSessionId === s.id ? c.bgActive : c.bgSearch,
fontSize: 12,
color: c.text
}}
>
💬 {s.title}
{s.archived && <Tag style={{ marginLeft: 6 }}></Tag>}
</div>
))}
</div>
)}
{searchHits.length > 0 && (
<div>
<div style={{ fontSize: 11, color: c.textDim, padding: '4px 8px' }}>
🔎 ({searchHits.length})
</div>
{searchHits.map((h) => (
<div
key={h.id}
onClick={() =>
onChange(h.sessionId, { highlightMessageId: h.id })
}
style={{
cursor: 'pointer',
padding: 8,
borderRadius: 6,
marginBottom: 4,
background: c.bgSearch,
borderLeft: `3px solid ${h.role === 'user' ? '#6366f1' : '#10b981'}`
}}
>
<div style={{ fontSize: 11, color: c.textDim, marginBottom: 2 }}>
{h.role === 'user' ? '我' : 'AI'} · {h.sessionTitle}
{h.sessionArchived && <Tag style={{ marginLeft: 4 }}></Tag>}
<span style={{ marginLeft: 6 }}>{dayjs(h.createdAt).format('MM-DD HH:mm')}</span>
</div>
<div
style={{ fontSize: 12, color: c.text, lineHeight: 1.4 }}
dangerouslySetInnerHTML={{ __html: h.snippet }}
/>
</div>
))}
</div>
)}
</>
)}
</div>
) : (
<>
<Segmented
size="small"
block
value={tab}
onChange={(v) => setTab(v as any)}
options={[
{ value: 'active', label: `💬 活跃 (${active.length})` },
{ value: 'archived', label: `📦 归档 (${archived.length})` }
]}
style={{ marginBottom: 8 }}
/>
{list.length === 0 ? (
<Empty description={tab === 'active' ? '无活跃会话' : '无归档会话'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={list}
style={{ flex: 1, overflow: 'auto' }}
renderItem={renderSession}
/>
)}
</>
)}
{/* 顶部全局样式 mark 标签高亮 */}
<style>{`
mark {
background: #fef3c7;
color: #b45309;
padding: 0 2px;
border-radius: 2px;
}
`}</style>
</div>
);
}

170
src/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,170 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { Dropdown, Avatar, Tooltip } from 'antd';
import {
SearchOutlined,
MessageOutlined,
RobotOutlined,
CompassOutlined,
BookOutlined,
ApartmentOutlined,
BarChartOutlined,
ApiOutlined,
TeamOutlined,
SunOutlined,
MoonOutlined,
LogoutOutlined
} from '@ant-design/icons';
import { useAuth } from '../store/auth';
import { useTheme } from '../main';
interface Props {
onOpenPalette?: () => void;
}
const NAV_GROUPS: Array<{
label: string;
items: Array<{ to: string; icon: React.ReactNode; label: string; end?: boolean }>;
}> = [
{
label: '工作台',
items: [
{ to: '/', icon: <MessageOutlined />, label: '聊天', end: true },
{ to: '/agents', icon: <RobotOutlined />, label: '我的智能体' },
{ to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' }
]
},
{
label: '资源',
items: [
{ to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' },
{ to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' }
]
},
{
label: '管理',
items: [
{ to: '/stats', icon: <BarChartOutlined />, label: '调用统计' },
{ to: '/llm-providers', icon: <ApiOutlined />, label: 'LLM 提供商' },
{ to: '/teams', icon: <TeamOutlined />, label: '团队' }
]
}
];
export default function Sidebar({ onOpenPalette }: Props) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { mode, toggle } = useTheme();
const isMac =
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
const cmdKey = isMac ? '⌘' : 'Ctrl';
return (
<aside className="sidebar">
<div className="brand">
<span className="brand-mark">A</span>
<span>Agent Studio</span>
<div style={{ flex: 1 }} />
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
</button>
</Tooltip>
</div>
<div
onClick={onOpenPalette}
className="nav-item"
style={{
cursor: 'pointer',
justifyContent: 'space-between',
background: 'var(--color-surface-2)',
marginBottom: 8
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SearchOutlined className="nav-icon" />
<span></span>
</span>
<span className="kbd">{cmdKey} K</span>
</div>
<div style={{ flex: 1, overflowY: 'auto', marginRight: -4, paddingRight: 4 }}>
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<div className="nav-section-label">{group.label}</div>
{group.items.map((it) => (
<NavLink
key={it.to}
to={it.to}
end={it.end}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{it.icon}</span>
<span>{it.label}</span>
</NavLink>
))}
</div>
))}
</div>
{user && (
<Dropdown
menu={{
items: [
{
key: 'name',
label: <span style={{ color: 'var(--color-text-tertiary)' }}>{user.email}</span>,
disabled: true
},
{ type: 'divider' },
{
key: 'role',
label: `身份:${user.role === 'admin' ? '管理员' : '普通用户'}`,
disabled: true
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: async () => {
await logout();
navigate('/login');
}
}
]
}}
placement="topLeft"
>
<div className="sidebar-user" style={{ marginTop: 8 }}>
<Avatar
size={32}
style={{ background: 'var(--gradient-brand)', flexShrink: 0 }}
>
{(user.name?.charAt(0) || '?').toUpperCase()}
</Avatar>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
color: 'var(--color-text)',
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: '-0.005em'
}}
>
{user.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
{user.role === 'admin' ? '管理员' : '成员'}
</div>
</div>
</div>
</Dropdown>
)}
</aside>
);
}

View File

@ -0,0 +1,190 @@
import { useEffect, useMemo, useState } from 'react';
import { Modal, Input, Select, Tabs, Alert, Space, Button, App as AntApp } from 'antd';
import { SKILL_TEMPLATES } from '../skillTemplates';
import { AgentAPI } from '../api';
interface Props {
open: boolean;
agentId: string;
skillId?: string | null;
onClose: () => void;
onSaved: () => void;
}
export default function SkillEditor({ open, agentId, skillId, onClose, onSaved }: Props) {
const isNew = !skillId;
const { message } = AntApp.useApp();
const [content, setContent] = useState('');
const [tplKey, setTplKey] = useState<'prompt' | 'http' | 'js'>('prompt');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (isNew) {
setContent(SKILL_TEMPLATES.prompt.content);
setTplKey('prompt');
} else if (skillId) {
AgentAPI.getSkill(agentId, skillId).then((d) => setContent(d.content || ''));
}
}, [open, skillId, isNew, agentId]);
// 简单解析 frontmatter仅前端预览用
const preview = useMemo(() => {
const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!m) return { warning: '⚠️ 未检测到 YAML frontmatter将作为 prompt 类型注入' };
const yaml = m[1];
const get = (k: string) => {
const r = new RegExp(`^${k}\\s*:\\s*(.+?)$`, 'm');
return yaml.match(r)?.[1]?.trim();
};
return {
name: get('name'),
description: get('description'),
type: get('type') || 'prompt',
handler: get('handler')
} as any;
}, [content]);
const handleSave = async () => {
setSaving(true);
try {
if (isNew) {
await AgentAPI.createSkill(agentId, {
name: preview.name || 'skill',
content
});
message.success('已创建');
} else {
await AgentAPI.updateSkill(agentId, skillId!, { content });
message.success('已保存');
}
onSaved();
onClose();
} catch (e: any) {
message.error('保存失败:' + (e?.message ?? e));
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
title={isNew ? '✨ 新建 Skill' : '✏️ 编辑 Skill'}
width={920}
onCancel={onClose}
onOk={handleSave}
okText="保存"
cancelText="取消"
confirmLoading={saving}
destroyOnHidden
>
{isNew && (
<Space style={{ marginBottom: 12 }}>
<span style={{ color: '#6b7280' }}></span>
<Select
value={tplKey}
style={{ width: 280 }}
onChange={(v) => {
setTplKey(v);
setContent(SKILL_TEMPLATES[v].content);
}}
options={Object.entries(SKILL_TEMPLATES).map(([k, v]) => ({
value: k,
label: v.label
}))}
/>
<Button
size="small"
onClick={() => {
setContent(SKILL_TEMPLATES[tplKey].content);
}}
>
</Button>
</Space>
)}
<Tabs
items={[
{
key: 'editor',
label: '编辑器',
children: (
<Input.TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ fontFamily: 'Consolas, Menlo, monospace', fontSize: 13 }}
autoSize={{ minRows: 18, maxRows: 26 }}
/>
)
},
{
key: 'meta',
label: '解析预览',
children: preview.warning ? (
<Alert type="warning" message={preview.warning} />
) : (
<div style={{ background: '#f9fafb', padding: 12, borderRadius: 6 }}>
<div>
<b>name:</b> {preview.name || '(未声明)'}
</div>
<div>
<b>type:</b> {preview.type}
</div>
<div>
<b>description:</b> {preview.description || '(无)'}
</div>
{preview.handler && (
<div>
<b>handler:</b> {preview.handler}
</div>
)}
<Alert
style={{ marginTop: 12 }}
type="info"
message="保存后服务端会重新解析 frontmattertype 必须是 prompt / http / js 之一。"
/>
</div>
)
},
{
key: 'help',
label: '语法说明',
children: (
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
<p>
Skill YAML frontmatter Markdown frontmatter
</p>
<pre style={{ background: '#f6f8fa', padding: 12, borderRadius: 6 }}>{`---
name: 线
description: LLM
type: prompt | http | js
parameters: # JSON Schema (function calling )
type: object
properties:
foo: { type: string }
required: [foo]
handler: ... # http: URL / js:
config: # method/headers/body/timeout/allowFetch
method: GET
---`}</pre>
<ul>
<li>
<b>prompt</b> SOP System Prompt function calling
</li>
<li>
<b>http</b>handler URL <code>{'{{var}}'}</code> method/headers/body
</li>
<li>
<b>js</b>handler JS 访 <code>args</code> <code>return</code>
</li>
</ul>
</div>
)
}
]}
/>
</Modal>
);
}

132
src/main.tsx Normal file
View File

@ -0,0 +1,132 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { ConfigProvider, App as AntApp, theme as antdTheme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
type ThemeMode = 'light' | 'dark';
interface ThemeCtx {
mode: ThemeMode;
toggle: () => void;
setMode: (m: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeCtx>({
mode: 'light',
toggle: () => {},
setMode: () => {}
});
export const useTheme = () => useContext(ThemeContext);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(() => {
const saved = localStorage.getItem('app-theme');
if (saved === 'dark' || saved === 'light') return saved;
return 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('app-theme', mode);
}, [mode]);
const isDark = mode === 'dark';
const themeConfig = useMemo(
() => ({
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
colorPrimary: isDark ? '#e07b3e' : '#c2541f',
colorInfo: isDark ? '#e07b3e' : '#c2541f',
colorBgBase: isDark ? '#1a1816' : '#faf9f5',
colorBgContainer: isDark ? '#221f1c' : '#ffffff',
colorBgElevated: isDark ? '#2b2824' : '#ffffff',
colorBgLayout: isDark ? '#1a1816' : '#faf9f5',
colorBorder: isDark ? '#36322c' : '#ebe7da',
colorBorderSecondary: isDark ? '#2b2824' : '#f0ece0',
colorText: isDark ? '#f3efe6' : '#2a2622',
colorTextSecondary: isDark ? '#b6afa3' : '#6b6660',
colorTextTertiary: isDark ? '#7e7869' : '#a09a8e',
colorSuccess: isDark ? '#7fb87d' : '#4f8a4d',
colorWarning: isDark ? '#d49a4a' : '#b8782a',
colorError: isDark ? '#e07060' : '#c0392b',
borderRadius: 10,
borderRadiusLG: 12,
borderRadiusSM: 6,
fontFamily:
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif",
fontSize: 14,
controlHeight: 36,
wireframe: false
},
components: {
Button: {
borderRadius: 10,
fontWeight: 500,
controlHeight: 36,
primaryShadow: 'none',
defaultShadow: 'none'
},
Card: {
borderRadiusLG: 14
},
Input: {
borderRadius: 10,
controlHeight: 38
},
Modal: {
borderRadiusLG: 16,
paddingContentHorizontalLG: 24
},
Tabs: {
itemHoverColor: isDark ? '#e07b3e' : '#c2541f',
itemSelectedColor: isDark ? '#e07b3e' : '#c2541f',
inkBarColor: isDark ? '#e07b3e' : '#c2541f'
},
Menu: {
itemBorderRadius: 8,
itemSelectedBg: isDark ? '#2d2017' : '#fdf2ea',
itemSelectedColor: isDark ? '#e07b3e' : '#c2541f'
},
Drawer: {
paddingLG: 20
},
Tag: {
borderRadiusSM: 6
}
}
}),
[isDark]
);
const ctxValue = useMemo<ThemeCtx>(
() => ({
mode,
toggle: () => setMode((m) => (m === 'dark' ? 'light' : 'dark')),
setMode
}),
[mode]
);
return (
<ThemeContext.Provider value={ctxValue}>
<ConfigProvider locale={zhCN} theme={themeConfig}>
<AntApp>{children}</AntApp>
</ConfigProvider>
</ThemeContext.Provider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>
);

986
src/pages/AgentEditor.tsx Normal file
View File

@ -0,0 +1,986 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, InputNumber, Modal, Upload, App as AntApp, List, Popconfirm, Tag, Switch, Select, Collapse } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import { useNavigate, useParams } from 'react-router-dom';
import { Agent, AgentAPI, ImageAPI, KnowledgeStatus, SkillType, Team, TeamAPI } from '../api';
import SkillEditor from '../components/SkillEditor';
import McpPanel from '../components/McpPanel';
import ChatPreview from '../components/ChatPreview';
import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined, RocketOutlined, ToolOutlined, DatabaseOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
const STATUS_TAG: Record<KnowledgeStatus, { color: string; text: string }> = {
pending: { color: 'default', text: '待处理' },
indexing: { color: 'processing', text: '索引中…' },
ready: { color: 'success', text: '已就绪' },
failed: { color: 'error', text: '失败' },
};
const TYPE_TAG: Record<SkillType, { color: string; icon: string; label: string }> = {
prompt: { color: 'blue', icon: '📝', label: 'Prompt' },
http: { color: 'green', icon: '🌐', label: 'HTTP' },
js: { color: 'volcano', icon: '⚙️', label: 'JS' },
};
const DEFAULT_AVATAR = '/default_bot_icon.jpg';
const PRESET_AVATARS: string[] = Array.from({ length: 12 }, (_, index) => `/avatars/avatar-${String(index + 1).padStart(2, '0')}.png`);
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
export default function AgentEditor() {
const { id } = useParams();
const isNew = !id;
const navigate = useNavigate();
const { message } = AntApp.useApp();
const [form] = Form.useForm();
const [initForm] = Form.useForm();
const [agent, setAgent] = useState<Agent | null>(null);
const [saving, setSaving] = useState(false);
const [teams, setTeams] = useState<Team[]>([]);
const [autoSaveStatus, setAutoSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved');
const [initModalOpen, setInitModalOpen] = useState(isNew);
const [selectedAvatar, setSelectedAvatar] = useState(DEFAULT_AVATAR);
const [avatarSelectorOpen, setAvatarSelectorOpen] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
// 监听名称变化以同步头像首字母
const [agentName, setAgentName] = useState('');
// skill editor
const [skillEditorOpen, setSkillEditorOpen] = useState(false);
const [editingSkillId, setEditingSkillId] = useState<string | null>(null);
const pollTimer = useRef<number | null>(null);
const refresh = async () => {
if (!id) return;
const data = await AgentAPI.detail(id);
setAgent(data);
form.setFieldsValue(data);
setAgentName(data.name);
setSelectedAvatar(data.avatar || DEFAULT_AVATAR);
// 若有索引中文件 → 启动轮询
const indexing = data.knowledge?.some((k) => k.status === 'pending' || k.status === 'indexing');
if (indexing && !pollTimer.current) {
pollTimer.current = window.setInterval(refresh, 2000);
} else if (!indexing && pollTimer.current) {
window.clearInterval(pollTimer.current);
pollTimer.current = null;
}
};
useEffect(() => {
TeamAPI.list()
.then(setTeams)
.catch(() => setTeams([]));
if (isNew) {
setInitModalOpen(true);
setSelectedAvatar(DEFAULT_AVATAR);
setAgentName('');
form.setFieldsValue({
name: '',
description: '',
prompt: 'You are a helpful AI assistant.',
model: '',
temperature: 0.7,
visibility: 'private',
teamId: null,
});
} else {
setInitModalOpen(false);
refresh();
}
return () => {
if (pollTimer.current) {
window.clearInterval(pollTimer.current);
pollTimer.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const handleInitConfirm = async () => {
const values = await initForm.validateFields();
setSaving(true);
try {
const created = await AgentAPI.create({
...values,
avatar: selectedAvatar,
prompt: 'You are a helpful AI assistant.',
temperature: 0.7,
visibility: 'private',
});
message.success('初始化成功');
setInitModalOpen(false);
navigate(`/agents/${created.id}`, { replace: true });
} catch (e) {
message.error('创建失败');
} finally {
setSaving(false);
}
};
const handleSave = async (silent = false) => {
if (isNew) return; // 初始弹窗未确认前不触发自动保存
const values = await form.validateFields();
if (!silent) setSaving(true);
setAutoSaveStatus('saving');
try {
await AgentAPI.update(id!, values);
if (!silent) message.success('已保存');
refresh();
setAutoSaveStatus('saved');
} catch (e) {
setAutoSaveStatus('error');
if (!silent) message.error('保存失败');
} finally {
if (!silent) setSaving(false);
}
};
const beforeUploadKnowledge = async (file: UploadFile) => {
if (!id) {
message.warning('请先保存智能体基础信息后再上传');
return Upload.LIST_IGNORE;
}
try {
await AgentAPI.uploadKnowledge(id, [file as unknown as File]);
message.success(`${file.name} 已上传,正在建索引…`);
refresh();
} catch (e: any) {
message.error('上传失败:' + (e?.message ?? e));
}
return Upload.LIST_IGNORE;
};
const uploadAvatar = async (file: File) => {
setAvatarUploading(true);
try {
const res = await ImageAPI.upload([file]);
const url = res.files?.[0]?.url;
if (!url) {
throw new Error('未获取到图片地址');
}
return url;
} finally {
setAvatarUploading(false);
}
};
const beforeUploadInitAvatar = async (file: UploadFile) => {
try {
const url = await uploadAvatar(file as unknown as File);
setSelectedAvatar(url);
message.success('头像上传成功');
} catch (e: any) {
message.error('头像上传失败:' + (e?.message ?? e));
}
return Upload.LIST_IGNORE;
};
const beforeUploadEditAvatar = async (file: UploadFile) => {
if (!id) return Upload.LIST_IGNORE;
try {
const url = await uploadAvatar(file as unknown as File);
await AgentAPI.update(id, { avatar: url });
message.success('头像已更新');
setAvatarSelectorOpen(false);
refresh();
} catch (e: any) {
message.error('头像上传失败:' + (e?.message ?? e));
}
return Upload.LIST_IGNORE;
};
const liveAgent = agent || (form.getFieldsValue() as Agent);
const currentName = liveAgent?.name || agentName || '未命名智能体';
return (
<>
<div
className="fixed inset-0 flex flex-col bg-white z-100 agent-editor-shell"
style={{ background: 'var(--color-bg)' }}
>
{/* Header */}
<header className="monica-header agent-editor-header">
<div className="flex items-center gap-4">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/agents')}
className="hover:bg-gray-100"
style={{ borderRadius: 12, width: 40, height: 40 }}
/>
<div>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: 'var(--color-brand)', fontSize: 18 }} />
<span
className="font-bold text-lg text-gray-800"
style={{ color: 'var(--color-text)' }}
>
{isNew ? '创建新智能体' : currentName}
</span>
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginTop: 2 }}> AI </div>
</div>
</div>
<div className="flex items-center gap-4">
<span
className="text-xs text-gray-400"
style={{ color: 'var(--color-text-tertiary)' }}
>
{autoSaveStatus === 'saving' ? '正在保存...' : '更改已自动保存'}
</span>
<Button
icon={<FileTextOutlined />}
style={{ borderRadius: 12, height: 40 }}
>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={() => handleSave()}
style={{ borderRadius: 12, height: 40, paddingInline: 16, fontWeight: 600 }}
>
</Button>
</div>
</header>
{/* Main Content */}
<div
className="flex-1 overflow-hidden agent-editor-workbench"
style={{ background: 'var(--color-bg)' }}
>
{/* Left Column: Personalization (System Prompt) */}
<div className="flex-1 agent-editor-pane">
<div className="agent-editor-pane-body">
<div className="agent-editor-pane-header">
<div>
<h3 className="agent-editor-pane-title"></h3>
<p className="agent-editor-pane-subtitle"></p>
</div>
<span className="agent-editor-badge">
<FileTextOutlined />
System Prompt
</span>
</div>
<div className="agent-editor-intro">
<div className="agent-editor-intro-title"></div>
<div className="agent-editor-intro-text"></div>
</div>
<Form
form={form}
layout="vertical"
onValuesChange={() => handleSave(true)}
>
<div className="agent-editor-surface agent-editor-prompt-wrap">
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10, paddingInline: 4 }}></div>
<div className="agent-editor-prompt">
<Form.Item
name="prompt"
noStyle
>
<Input.TextArea
rows={30}
placeholder="在这里输入智能体的人设、技能、风格和输出规范..."
className="border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
style={{ height: 'calc(100vh - 290px)', resize: 'none', borderRadius: 16 }}
/>
</Form.Item>
</div>
</div>
<div
className="text-[11px] text-gray-400 mt-2 px-2"
style={{ color: 'var(--color-text-tertiary)' }}
>
</div>
</Form>
</div>
</div>
{/* Middle Column: Capabilities & Basic Info */}
<div className="flex-1 agent-editor-pane">
<div className="agent-editor-pane-body">
<div className="agent-editor-pane-header">
<div>
<h3 className="agent-editor-pane-title"></h3>
<p className="agent-editor-pane-subtitle"></p>
</div>
<span className="agent-editor-badge">
<ToolOutlined />
Capability
</span>
</div>
<Form
form={form}
layout="vertical"
onValuesChange={() => handleSave(true)}
>
<div
className="agent-editor-intro"
style={{ marginBottom: 18 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
className="rounded-full flex items-center justify-center text-2xl text-white font-bold shadow-md cursor-pointer overflow-hidden group relative"
style={{
width: 72,
height: 72,
background: isImageUrl(agent?.avatar || selectedAvatar) ? '#f3f4f6' : agent?.avatar || selectedAvatar,
border: '3px solid rgba(255,255,255,0.92)',
}}
onClick={() => setAvatarSelectorOpen(true)}
>
{isImageUrl(agent?.avatar || selectedAvatar) ? (
<img
src={agent?.avatar || selectedAvatar}
className="w-full h-full object-cover"
alt="avatar"
/>
) : (
(agentName?.charAt(0) || '?').toUpperCase()
)}
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<span className="text-[10px] text-white font-medium"></span>
</div>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>{currentName}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 8 }}>{liveAgent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'}</div>
<Button
size="small"
type="default"
className="rounded-lg h-8"
onClick={() => setAvatarSelectorOpen(true)}
>
</Button>
</div>
</div>
</div>
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse-bordered mb-4"
defaultActiveKey={['basic']}
items={[
{
key: 'basic',
label: (
<div
className="flex items-center gap-2 font-medium text-gray-700"
style={{ color: 'var(--color-text)' }}
>
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
</div>
),
children: (
<div className="space-y-4">
<div className="flex items-center gap-4 mb-2">
<div className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400"></span>
</div>
</div>
<Form.Item
name="name"
label="名称"
rules={[{ required: true }]}
className="mb-3"
>
<Input
placeholder="智能体名称"
onChange={(e) => setAgentName(e.target.value)}
style={{ borderRadius: 12, height: 42 }}
/>
</Form.Item>
<Form.Item
name="description"
label="描述"
className="mb-3"
>
<Input.TextArea
rows={3}
placeholder="简短描述这个智能体适合做什么..."
style={{ borderRadius: 12 }}
/>
</Form.Item>
<div className="flex gap-2">
<Form.Item
name="visibility"
label="可见性"
className="flex-1 mb-0"
>
<Select
size="middle"
style={{ minHeight: 42 }}
options={[
{ value: 'private', label: '🔒 私有' },
{ value: 'team', label: '👥 团队' },
{ value: 'public', label: '🌐 公开' },
]}
/>
</Form.Item>
<Form.Item
name="teamId"
label="归属团队"
className="flex-1 mb-0"
>
<Select
size="middle"
placeholder="选择团队"
allowClear
style={{ minHeight: 42 }}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</div>
</div>
),
},
]}
/>
<div
className="monica-card !mb-4"
style={{ borderRadius: 18 }}
>
<div className="flex items-center gap-2 mb-4 font-medium text-gray-700">
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<div className="space-y-4">
<Form.Item
name="model"
label="模型"
className="mb-0"
>
<Input
placeholder="默认模型"
style={{ borderRadius: 12, height: 42 }}
/>
</Form.Item>
<Form.Item
name="temperature"
label="Temperature"
className="mb-0"
>
<div className="flex items-center gap-4">
<InputNumber
min={0}
max={2}
step={0.1}
className="w-20"
style={{ borderRadius: 12, height: 42 }}
/>
<span className="text-[11px] text-gray-400"></span>
</div>
</Form.Item>
</div>
</div>
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse"
items={[
{
key: 'knowledge',
label: (
<div
className="flex items-center gap-2 font-medium text-gray-700"
style={{ color: 'var(--color-text)' }}
>
<DatabaseOutlined style={{ color: 'var(--color-brand)' }} />
({agent?.knowledge?.length ?? 0})
</div>
),
children: (
<div className="px-1">
<div className="flex justify-between items-center mb-4">
<span className="text-xs text-gray-500"> AI </span>
<Upload
multiple
beforeUpload={beforeUploadKnowledge as any}
showUploadList={false}
>
<Button
type="primary"
size="small"
ghost
icon={<DatabaseOutlined />}
style={{ borderRadius: 10 }}
>
</Button>
</Upload>
</div>
<List
size="small"
dataSource={agent?.knowledge ?? []}
renderItem={(item) => (
<List.Item
className="bg-white mb-2 rounded-lg border border-gray-100 p-2"
actions={[
<Popconfirm
key="del"
title="确认删除?"
onConfirm={() => AgentAPI.deleteKnowledge(id!, item.id).then(refresh)}
>
<Button
type="text"
danger
size="small"
style={{ borderRadius: 8 }}
>
</Button>
</Popconfirm>,
]}
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="text-sm font-medium truncate">{item.originalName}</span>
<span className="text-[10px] text-gray-400">
{(item.size / 1024).toFixed(1)} KB ·{' '}
<Tag
color={STATUS_TAG[item.status].color}
className="m-0 text-[10px] px-1"
>
{STATUS_TAG[item.status].text}
</Tag>
</span>
</div>
</List.Item>
)}
/>
</div>
),
},
{
key: 'skills',
label: (
<div
className="flex items-center gap-2 font-medium text-gray-700"
style={{ color: 'var(--color-text)' }}
>
<ToolOutlined style={{ color: 'var(--color-brand)' }} />
& ({agent?.skills?.length ?? 0})
</div>
),
children: (
<div className="px-1">
<div className="flex justify-between items-center mb-4">
<span className="text-xs text-gray-500"> AI </span>
<Button
type="primary"
size="small"
ghost
style={{ borderRadius: 10 }}
onClick={() => {
setEditingSkillId(null);
setSkillEditorOpen(true);
}}
>
</Button>
</div>
<List
size="small"
dataSource={agent?.skills ?? []}
renderItem={(item) => (
<List.Item
className="bg-white mb-2 rounded-lg border border-gray-100 p-2"
actions={[
<Switch
key="toggle"
size="small"
checked={!!item.enabled}
onChange={(v) => AgentAPI.updateSkill(id!, item.id, { enabled: v }).then(refresh)}
/>,
<Button
key="edit"
type="text"
size="small"
style={{ borderRadius: 8 }}
onClick={() => {
setEditingSkillId(item.id);
setSkillEditorOpen(true);
}}
>
</Button>,
]}
>
<div className="flex items-center gap-2 overflow-hidden">
<Tag
color={TYPE_TAG[item.type].color}
className="m-0 text-[10px]"
>
{TYPE_TAG[item.type].label}
</Tag>
<span className="text-sm font-medium truncate">{item.name}</span>
</div>
</List.Item>
)}
/>
</div>
),
},
{
key: 'mcp',
label: (
<div
className="flex items-center gap-2 font-medium text-gray-700"
style={{ color: 'var(--color-text)' }}
>
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
MCP
</div>
),
children: id ? <McpPanel agentId={id} /> : <div className="text-center text-gray-400 py-4"> MCP</div>,
},
]}
/>
<div
className="monica-card mt-6"
style={{ borderRadius: 18 }}
>
<div className="flex items-center justify-between mb-4">
<div
className="flex items-center gap-2 font-medium text-gray-700"
style={{ color: 'var(--color-text)' }}
>
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<Form.Item
name="webSearchEnabled"
valuePropName="checked"
className="mb-0"
>
<Switch size="small" />
</Form.Item>
</div>
<div className="text-[11px] text-gray-400"> DuckDuckGo </div>
</div>
</Form>
</div>
</div>
{/* Right Column: Preview */}
<div className="flex-1 h-full agent-editor-pane">
<div className="agent-editor-pane-body agent-editor-preview-shell">
<div className="agent-editor-pane-header">
<div>
<h3 className="agent-editor-pane-title"></h3>
<p className="agent-editor-pane-subtitle"></p>
</div>
<span className="agent-editor-badge">
<RocketOutlined />
Live Preview
</span>
</div>
<div
className="agent-editor-intro"
style={{ marginBottom: 14 }}
>
<div className="agent-editor-intro-title"></div>
<div className="agent-editor-intro-text">{currentName} Prompt </div>
</div>
<div
className="agent-editor-surface"
style={{ flex: 1, overflow: 'hidden' }}
>
<ChatPreview
agent={liveAgent}
agentId={id}
/>
</div>
</div>
</div>
</div>
{!isNew && (
<SkillEditor
open={skillEditorOpen}
agentId={id!}
skillId={editingSkillId}
onClose={() => setSkillEditorOpen(false)}
onSaved={refresh}
/>
)}
</div>
<Modal
title={null}
open={initModalOpen}
onCancel={() => navigate('/marketplace')}
footer={null}
width={720}
centered
maskClosable={false}
destroyOnHidden
>
<div className="py-2">
<div style={{ marginBottom: 18 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.08)',
color: 'var(--color-brand)',
fontSize: 12,
fontWeight: 600,
marginBottom: 14,
}}
>
<RocketOutlined />
·
</div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}></div>
<div style={{ fontSize: 14.5, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}></div>
</div>
<div className="agent-editor-modal-hero">
<div
className="agent-editor-modal-card"
style={{
padding: '22px 20px',
background: 'linear-gradient(180deg, rgba(236,253,245,0.96) 0%, rgba(240,249,255,0.96) 100%)',
}}
>
<div
className="rounded-full flex items-center justify-center text-3xl text-white font-bold shadow-xl relative transition-all duration-500 overflow-hidden ring-4 ring-white mx-auto"
style={{
width: 88,
height: 88,
background: isImageUrl(selectedAvatar) ? '#f3f4f6' : selectedAvatar,
}}
>
{isImageUrl(selectedAvatar) ? (
<img
src={selectedAvatar}
className="w-full h-full object-cover"
alt="avatar"
/>
) : (
(agentName?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>{agentName || '你的新智能体'}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}></div>
</div>
</div>
<Form
form={initForm}
layout="vertical"
onFinish={handleInitConfirm}
onValuesChange={(changed) => {
if (changed.name !== undefined) setAgentName(changed.name);
}}
className="agent-editor-modal-card"
style={{ padding: 20 }}
>
<Form.Item
name="name"
label={
<span
className="text-gray-500 font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
</span>
}
rules={[{ required: true, message: '请输入智能体名称' }]}
>
<Input
placeholder="给你的智能体起个名字"
size="large"
autoFocus
className="rounded-xl h-12 border-gray-200 focus:border-cyan-500"
/>
</Form.Item>
<Form.Item
name="description"
label={
<span
className="text-gray-500 font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
</span>
}
>
<Input.TextArea
placeholder="介绍一下这个智能体是做什么的..."
rows={3}
className="rounded-xl border-gray-200 focus:border-cyan-500"
/>
</Form.Item>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
{['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => (
<span
key={label}
style={{
padding: '6px 10px',
borderRadius: 999,
background: 'var(--color-surface-2)',
color: 'var(--color-text-secondary)',
fontSize: 12.5,
}}
>
{label}
</span>
))}
</div>
<div className="flex gap-4 pt-2">
<Button
onClick={() => navigate('/marketplace')}
className="flex-1 h-12 rounded-xl border-gray-200 text-gray-500 font-semibold hover:bg-gray-50"
>
</Button>
<Button
type="primary"
htmlType="submit"
loading={saving}
className="flex-1 h-12 rounded-xl border-none font-semibold"
>
</Button>
</div>
</Form>
</div>
<div>
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-3 px-1"></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>使</div>
<Upload
accept="image/png,image/jpeg,image/webp,image/gif"
showUploadList={false}
beforeUpload={beforeUploadInitAvatar as any}
>
<Button
icon={<UploadOutlined />}
loading={avatarUploading}
style={{ borderRadius: 10 }}
>
</Button>
</Upload>
</div>
<div className="agent-editor-avatar-grid monica-scrollbar">
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
<div
key={url}
onClick={() => setSelectedAvatar(url)}
className={`relative aspect-square rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 ${selectedAvatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`}
style={{ borderColor: selectedAvatar === url ? 'var(--color-brand)' : 'transparent' }}
>
<img
src={url}
className="w-full h-full object-cover"
alt="preset"
/>
{selectedAvatar === url && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
>
<div className="bg-white rounded-full p-0.5 shadow-sm">
<div
className="w-2 h-2 rounded-full"
style={{ background: 'var(--color-brand)' }}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</Modal>
<Modal
title="选择头像形象"
open={avatarSelectorOpen}
onCancel={() => setAvatarSelectorOpen(false)}
footer={null}
width={520}
centered
>
<div className="py-4">
<div className="flex items-center justify-between gap-3 mb-4">
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest"></div>
<Upload
accept="image/png,image/jpeg,image/webp,image/gif"
showUploadList={false}
beforeUpload={beforeUploadEditAvatar as any}
>
<Button
icon={<UploadOutlined />}
loading={avatarUploading}
style={{ borderRadius: 10 }}
>
</Button>
</Upload>
</div>
<div className="grid grid-cols-6 gap-3 bg-gray-50 p-4 rounded-2xl max-h-[400px] overflow-y-auto monica-scrollbar">
{/* 默认与内置图片 */}
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
<div
key={url}
onClick={async () => {
await AgentAPI.update(id!, { avatar: url });
setAvatarSelectorOpen(false);
refresh();
}}
className={`relative aspect-square rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 ${agent?.avatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`}
style={{ borderColor: agent?.avatar === url ? 'var(--color-brand)' : 'transparent' }}
>
<img
src={url}
className="w-full h-full object-cover"
alt="preset"
/>
{agent?.avatar === url && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'rgba(194, 84, 31, 0.08)' }}
>
<div className="bg-white rounded-full p-0.5 shadow-sm">
<div
className="w-2 h-2 rounded-full"
style={{ background: 'var(--color-brand)' }}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
</Modal>
</>
);
}

285
src/pages/AgentList.tsx Normal file
View File

@ -0,0 +1,285 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowRightOutlined,
CompassOutlined,
DeleteOutlined,
EditOutlined,
MessageOutlined,
RobotOutlined
} from '@ant-design/icons';
import { Button, Col, Row, Empty, Popconfirm, App as AntApp, Tag, Space } from 'antd';
import { Link, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { Agent, AgentAPI } from '../api';
export default function AgentList() {
const [list, setList] = useState<Agent[]>([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { message } = AntApp.useApp();
const load = async () => {
setLoading(true);
try {
setList(await AgentAPI.list());
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleDelete = async (id: string) => {
await AgentAPI.remove(id);
message.success('已删除');
load();
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]);
const teamCount = useMemo(() => list.filter((a) => a.visibility === 'team').length, [list]);
return (
<div className="page-container">
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 22
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<RobotOutlined style={{ color: 'var(--color-brand)' }} />
Agent
</div>
<h2 className="page-title" style={{ marginBottom: 10 }}>
</h2>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
AI 广
</div>
</div>
<Button
type="primary"
size="large"
icon={<CompassOutlined />}
onClick={() => navigate('/marketplace')}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
广
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '已创建智能体', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '公开可分享', value: publicCount, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '团队协作中', value: teamCount, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{!loading && list.length === 0 ? (
<div className="empty-state">
<Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 10 }}>
广
</Button>
</Empty>
</div>
) : (
<Row gutter={[18, 18]}>
{list.map((a) => (
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
<div
className="agent-card"
style={{
borderRadius: 20,
padding: 20,
minHeight: 292,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
className="avatar"
style={{ background: a.avatar || 'var(--gradient-brand)', borderRadius: '50%', overflow: 'hidden', width: 54, height: 54 }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--color-text)', marginBottom: 4 }}>{a.name}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
{dayjs(a.updated_at).format('YYYY-MM-DD')}
</div>
</div>
</div>
<div
style={{
marginTop: 16,
padding: '16px 16px 18px',
borderRadius: 16,
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)'
}}
>
<div className="desc" style={{ minHeight: 66, fontSize: 13.5, lineHeight: 1.7 }}>
{a.description || '还没有填写描述,可以补充这个智能体适合解决什么问题。'}
</div>
</div>
<Space size={6} wrap style={{ marginTop: 14 }}>
{a.visibility === 'public' && (
<Tag bordered={false} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.visibility === 'team' && (
<Tag bordered={false} style={{ background: 'var(--color-info-soft)', color: 'var(--color-info)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.visibility === 'private' && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.model && (
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%' }}>
<span style={{ display: 'inline-block', maxWidth: 190, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.model}
</span>
</Tag>
)}
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
T={a.temperature}
</Tag>
{(a.fork_count ?? 0) > 0 && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
Fork {a.fork_count}
</Tag>
)}
</Space>
<div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Link to={`/agents/${a.id}/chat`} style={{ flex: 1 }}>
<Button type="primary" block icon={<MessageOutlined />} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
</Link>
<Link to={`/agents/${a.id}`} style={{ flex: 1 }}>
<Button block icon={<EditOutlined />} style={{ borderRadius: 12, height: 40 }}>
</Button>
</Link>
<Popconfirm
title="确定删除该智能体?"
description="将删除其知识库与对话记录"
onConfirm={() => handleDelete(a.id)}
okText="删除"
cancelText="取消"
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12, width: 40, height: 40 }} />
</Popconfirm>
</div>
</div>
</Col>
))}
</Row>
)}
{list.length > 0 && (
<div
style={{
marginTop: 24,
borderRadius: 20,
padding: '18px 20px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap'
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
广
</div>
</div>
<Button type="text" icon={<ArrowRightOutlined />} onClick={() => navigate('/marketplace')} style={{ color: 'var(--color-brand)', fontWeight: 600 }}>
广
</Button>
</div>
)}
</div>
);
}

1206
src/pages/ChatPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,420 @@
import { useEffect, useState } from 'react';
import {
ApiOutlined,
CheckCircleOutlined,
LockOutlined,
PlusOutlined,
RocketOutlined,
StarFilled
} from '@ant-design/icons';
import {
Button,
Modal,
Form,
Input,
Select,
Space,
Tag,
App as AntApp,
Empty,
Popconfirm,
Tooltip
} from 'antd';
import { LLMKind, LLMProvider, LLMProviderAPI } from '../api';
const KIND_OPTIONS: { value: LLMKind; label: string; baseUrl: string; hint?: string }[] = [
{ value: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1' },
{
value: 'openai-compatible',
label: 'OpenAI 兼容GLM/通义/DeepSeek/腾讯 Token Plan',
baseUrl: '',
hint: '智谱https://open.bigmodel.cn/api/paas/v4 · 通义https://dashscope.aliyuncs.com/compatible-mode/v1 · DeepSeekhttps://api.deepseek.com'
},
{ value: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com' },
{ value: 'ollama', label: 'Ollama 本地', baseUrl: 'http://localhost:11434' }
];
export default function LLMProvidersPage() {
const { message } = AntApp.useApp();
const [list, setList] = useState<LLMProvider[]>([]);
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<LLMProvider | null>(null);
const [form] = Form.useForm();
const [testing, setTesting] = useState<string | null>(null);
const load = async () => {
try {
setList(await LLMProviderAPI.list());
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ kind: 'openai-compatible', enabled: true, isDefault: false });
setEditorOpen(true);
};
const openEdit = (p: LLMProvider) => {
setEditing(p);
form.setFieldsValue({
name: p.name,
kind: p.kind,
baseUrl: p.baseUrl,
models: p.models?.join(', ') || '',
defaultModel: p.defaultModel,
enabled: p.enabled,
isDefault: p.isDefault
});
setEditorOpen(true);
};
const onSave = async () => {
const v = await form.validateFields();
const payload = {
...v,
models: typeof v.models === 'string'
? v.models.split(/[,]/).map((s: string) => s.trim()).filter(Boolean)
: v.models
};
try {
if (editing) {
await LLMProviderAPI.update(editing.id, payload);
message.success('已更新');
} else {
await LLMProviderAPI.create(payload);
message.success('已创建');
}
setEditorOpen(false);
load();
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
}
};
const onDelete = async (p: LLMProvider) => {
await LLMProviderAPI.remove(p.id);
message.success('已删除');
load();
};
const onTest = async (p: LLMProvider) => {
setTesting(p.id);
try {
const r = await LLMProviderAPI.test(p.id, p.defaultModel);
if (r.ok) {
message.success(`✅ 连通:${r.model} · 用量 ${(r as any).usage?.TotalTokens ?? '?'} tokens`);
} else {
message.error('❌ 失败:' + (r.error || 'unknown'));
}
} catch (e: any) {
message.error('测试失败:' + (e?.response?.data?.error ?? e?.message ?? e));
} finally {
setTesting(null);
}
};
const onSetDefault = async (p: LLMProvider) => {
await LLMProviderAPI.setDefault(p.id);
message.success('已设为默认');
load();
};
return (
<div className="page-container" style={{ maxWidth: 1080 }}>
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 20
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<ApiOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}>LLM </h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
</div>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
setEditorOpen(true);
form.resetFields();
form.setFieldsValue({ kind: 'openai', enabled: true });
}}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '已接入提供商', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '可用连接数', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '默认模型源', value: list.filter((item) => item.isDefault).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{list.length === 0 ? (
<div
style={{
borderRadius: 22,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '54px 24px'
}}
>
<Empty description="暂无 LLM 提供商,点击右上角添加第一个模型源" />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', gap: 18 }}>
{list.map((p) => (
<div
key={p.id}
style={{
borderRadius: 20,
border: '1px solid var(--color-border)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
padding: 20,
minHeight: 310,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 6 }}>
<span style={{ fontWeight: 700, fontSize: 18, color: 'var(--color-text)' }}>{p.name}</span>
{p.isDefault && (
<Tag bordered={false} icon={<StarFilled />} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{!p.enabled && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>{p.baseUrl}</div>
</div>
<Space size={4}>
<Tooltip title="测试连通性(会发一条 ping 消息)">
<Button size="small" loading={testing === p.id} onClick={() => onTest(p)} style={{ borderRadius: 10 }}>
</Button>
</Tooltip>
{!p.isDefault && (
<Tooltip title="设为我的默认 provider">
<Button size="small" onClick={() => onSetDefault(p)} style={{ borderRadius: 10 }}>
</Button>
</Tooltip>
)}
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>
{p.kind}
</Tag>
</div>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{p.defaultModel || '未设置'}</div>
</div>
</div>
<div
style={{
borderRadius: 16,
padding: '16px 16px 14px',
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
marginBottom: 16
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
<LockOutlined />
</div>
<div style={{ fontSize: 13.5, color: 'var(--color-text)' }}>
{p.hasApiKey ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<CheckCircleOutlined style={{ color: 'var(--color-success)' }} />
{p.apiKeyMasked}
</span>
) : (
<span style={{ color: 'var(--color-danger)' }}> API Key</span>
)}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 8, wordBreak: 'break-all' }}>{p.baseUrl}</div>
</div>
{p.models?.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}></div>
<Space size={6} wrap>
{p.models.map((m) => (
<Tag key={m} bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
{m}
</Tag>
))}
</Space>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', flex: 1 }}>
{p.enabled ? '可用于聊天、工作流和测试连接' : '当前已暂停使用,需要重新启用'}
</span>
<Space size={4}>
<Button size="small" type="text" onClick={() => openEdit(p)} style={{ borderRadius: 10 }}>
</Button>
<Popconfirm title={`删除 ${p.name}`} onConfirm={() => onDelete(p)}>
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
</Button>
</Popconfirm>
</Space>
</div>
</div>
))}
</div>
)}
<Modal
open={editorOpen}
title={editing ? '编辑 Provider' : '添加 Provider'}
onCancel={() => setEditorOpen(false)}
onOk={onSave}
width={600}
okText="保存"
cancelText="取消"
destroyOnHidden
>
<Form
form={form}
layout="vertical"
onValuesChange={(changed) => {
if (changed.kind) {
const opt = KIND_OPTIONS.find((o) => o.value === changed.kind);
if (opt?.baseUrl && !form.getFieldValue('baseUrl')) {
form.setFieldsValue({ baseUrl: opt.baseUrl });
}
}
}}
>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="例如:我的 OpenAI" />
</Form.Item>
<Form.Item name="kind" label="类型" rules={[{ required: true }]}>
<Select>
{KIND_OPTIONS.map((k) => (
<Select.Option key={k.value} value={k.value}>
{k.label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[{ required: true }]}
tooltip={KIND_OPTIONS.find((k) => k.value === form.getFieldValue('kind'))?.hint}
>
<Input placeholder="https://api.openai.com/v1" />
</Form.Item>
<Form.Item
name="apiKey"
label={editing ? 'API Key留空保留原值' : 'API Key'}
rules={editing ? [] : [{ required: true }]}
>
<Input.Password placeholder="sk-..." autoComplete="new-password" />
</Form.Item>
<Form.Item
name="models"
label="可用模型(逗号分隔)"
tooltip="用于在 agent 编辑器中下拉选择"
>
<Input placeholder="gpt-4o, gpt-4o-mini" />
</Form.Item>
<Form.Item name="defaultModel" label="默认模型">
<Input placeholder="例如 gpt-4o-mini" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

319
src/pages/LoginPage.tsx Normal file
View File

@ -0,0 +1,319 @@
import { useState } from 'react';
import { Form, Input, Button, Tabs, App as AntApp, Alert } from 'antd';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../store/auth';
export default function LoginPage() {
const [tab, setTab] = useState<'login' | 'register'>('login');
const navigate = useNavigate();
const [params] = useSearchParams();
const next = params.get('next') || '/';
const { login, register } = useAuth();
const { message } = AntApp.useApp();
const [loading, setLoading] = useState(false);
const onLogin = async (values: any) => {
setLoading(true);
try {
await login(values.email, values.password);
message.success('登录成功');
navigate(next, { replace: true });
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '登录失败');
} finally {
setLoading(false);
}
};
const onRegister = async (values: any) => {
setLoading(true);
try {
await register({
email: values.email,
password: values.password,
name: values.name,
inviteCode: values.inviteCode || undefined
});
message.success('注册成功,已自动登录');
navigate(next, { replace: true });
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '注册失败');
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
background: 'var(--gradient-hero)',
position: 'relative',
overflow: 'hidden'
}}
>
{/* 装饰光斑 */}
<div
style={{
position: 'absolute',
top: '-160px',
right: '-120px',
width: 480,
height: 480,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(224,123,62,0.18), transparent 60%)',
filter: 'blur(40px)'
}}
/>
<div
style={{
position: 'absolute',
bottom: '-180px',
left: '-120px',
width: 520,
height: 520,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(194,84,31,0.16), transparent 60%)',
filter: 'blur(40px)'
}}
/>
{/* 左侧品牌区 */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px 80px',
position: 'relative',
zIndex: 1
}}
className="login-brand-panel"
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 40 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 12,
background: 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 18,
fontWeight: 700,
boxShadow: 'var(--shadow-md)'
}}
>
A
</div>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>
Agent Studio
</span>
</div>
<h1
style={{
fontSize: 48,
lineHeight: 1.15,
fontWeight: 700,
letterSpacing: '-0.03em',
color: 'var(--color-text)',
margin: 0,
maxWidth: 520
}}
>
<br />
<span
style={{
background: 'var(--gradient-brand)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}
>
AI
</span>
</h1>
<p
style={{
fontSize: 16,
color: 'var(--color-text-secondary)',
marginTop: 20,
maxWidth: 480,
lineHeight: 1.7
}}
>
AI
</p>
<div style={{ display: 'flex', gap: 24, marginTop: 40, flexWrap: 'wrap' }}>
{[
{ icon: '✦', text: '多模型即插即用' },
{ icon: '✦', text: '知识库 + RAG 检索' },
{ icon: '✦', text: '可分享智能体' }
].map((it) => (
<div
key={it.text}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--color-text-secondary)'
}}
>
<span style={{ color: 'var(--color-brand)' }}>{it.icon}</span>
{it.text}
</div>
))}
</div>
</div>
{/* 右侧表单 */}
<div
style={{
width: 480,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
position: 'relative',
zIndex: 1
}}
>
<div
style={{
width: '100%',
maxWidth: 380,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 20,
padding: '36px 32px',
boxShadow: 'var(--shadow-xl)'
}}
>
<div style={{ marginBottom: 24 }}>
<h2
style={{
margin: 0,
fontSize: 22,
fontWeight: 700,
color: 'var(--color-text)',
letterSpacing: '-0.01em'
}}
>
</h2>
<div
style={{ color: 'var(--color-text-secondary)', fontSize: 13, marginTop: 6 }}
>
使
</div>
</div>
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as any)}
items={[
{
key: 'login',
label: '登录',
children: (
<Form layout="vertical" onFinish={onLogin} style={{ marginTop: 8 }}>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, type: 'email', message: '请填写合法邮箱' }]}
>
<Input placeholder="you@example.com" size="large" autoFocus />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password placeholder="••••••" size="large" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
style={{ marginTop: 4, height: 44, fontWeight: 600 }}
>
</Button>
</Form>
)
},
{
key: 'register',
label: '注册',
children: (
<Form layout="vertical" onFinish={onRegister} style={{ marginTop: 8 }}>
<Alert
style={{
marginBottom: 16,
borderRadius: 10,
background: 'var(--color-brand-soft)',
border: '1px solid var(--color-brand-soft-2)',
color: 'var(--color-text-secondary)'
}}
type="info"
showIcon
message="第一个注册的用户自动成为管理员;之后需要邀请码"
/>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, type: 'email' }]}
>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item name="name" label="昵称" rules={[{ required: true }]}>
<Input placeholder="张三" size="large" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, min: 6, message: '至少 6 位' }]}
>
<Input.Password placeholder="••••••" size="large" />
</Form.Item>
<Form.Item name="inviteCode" label="邀请码(可选)">
<Input placeholder="如果你是被邀请的用户" size="large" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
style={{ marginTop: 4, height: 44, fontWeight: 600 }}
>
</Button>
</Form>
)
}
]}
/>
<div
style={{
textAlign: 'center',
fontSize: 12,
color: 'var(--color-text-tertiary)',
marginTop: 20
}}
>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,272 @@
import { useEffect, useState } from 'react';
import { Col, Row, Empty, Button, Tag, Space, App as AntApp, Input, Spin } from 'antd';
import { useNavigate } from 'react-router-dom';
import { MarketplaceAPI, MarketplaceAgent } from '../api';
import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@ant-design/icons';
export default function MarketplacePage() {
const [list, setList] = useState<MarketplaceAgent[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const navigate = useNavigate();
const { message } = AntApp.useApp();
const load = async () => {
setLoading(true);
try {
setList(await MarketplaceAPI.list());
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const filtered = q
? list.filter(
(a) =>
a.name.includes(q) ||
a.description?.includes(q) ||
(a.ownerName || '').includes(q)
)
: list;
const handleFork = async (a: MarketplaceAgent) => {
try {
const r = await MarketplaceAPI.fork(a.id);
message.success(`已复制到「${r.name}`);
navigate(`/agents/${r.id}`);
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '复制失败');
}
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
return (
<div>
<div className="page-hero">
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
borderRadius: 999,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 500,
marginBottom: 18
}}
>
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="hero-title"> AI </h1>
<p className="hero-subtitle">
</p>
</div>
</div>
<div className="page-container" style={{ paddingTop: 28 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 28,
gap: 20,
alignItems: 'center',
flexWrap: 'wrap'
}}
>
<div style={{ flex: 1, minWidth: 280, maxWidth: 520 }}>
<Input
placeholder="搜索智能体名称、描述或作者..."
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ height: 44, borderRadius: 12 }}
allowClear
/>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => navigate('/agents/new')}
style={{
height: 44,
padding: '0 24px',
borderRadius: 12,
fontWeight: 600
}}
>
</Button>
</div>
{loading ? (
<div style={{ padding: '60px 0', textAlign: 'center' }}>
<Spin size="large" />
</div>
) : (
<Row gutter={[20, 20]}>
{/* Create New Card (First Item) */}
{!q && (
<Col xs={24} sm={12} md={8} lg={6}>
<div onClick={() => navigate('/agents/new')} className="create-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div className="create-icon">
<PlusOutlined style={{ fontSize: 24, color: '#0891b2' }} />
</div>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--color-text)', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 12 }}>
</div>
<div className="desc" style={{ minHeight: 44 }}>
AI
</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<Button
type="default"
block
style={{
height: 40,
borderRadius: 10,
fontWeight: 600,
borderStyle: 'dashed'
}}
>
</Button>
</div>
</div>
</Col>
)}
{filtered.map((a) => (
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
<div className="agent-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div
style={{
width: 52,
height: 52,
borderRadius: '50%',
background: a.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 22,
boxShadow: 'var(--shadow-sm)',
overflow: 'hidden'
}}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
{a.fork_count > 10 && (
<Tag
bordered={false}
icon={<FireOutlined />}
style={{
borderRadius: 999,
margin: 0,
background: 'var(--color-warning-soft)',
color: 'var(--color-warning)'
}}
>
</Tag>
)}
</div>
<div style={{ marginTop: 16 }}>
<div
style={{
fontWeight: 700,
fontSize: 17,
color: 'var(--color-text)',
marginBottom: 4,
letterSpacing: '-0.01em'
}}
>
{a.name}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 12 }}>
by {a.ownerName || '匿名作者'}
</div>
<div className="desc" style={{ minHeight: 44 }}>{a.description || '暂无详细描述'}</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<Space size={4} wrap style={{ marginBottom: 16 }}>
{a.kbCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
borderRadius: 999
}}
>
📚 {a.kbCount}
</Tag>
)}
{a.skillCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-success-soft)',
color: 'var(--color-success)',
borderRadius: 999
}}
>
🛠 {a.skillCount}
</Tag>
)}
</Space>
<Button
type="default"
block
onClick={() => handleFork(a)}
style={{
height: 40,
borderRadius: 10,
fontWeight: 600
}}
>
📥
</Button>
</div>
</div>
</Col>
))}
</Row>
)}
{filtered.length === 0 && !loading && (
<Empty description="没有找到匹配的智能体" style={{ marginTop: 80 }} />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,494 @@
import { useEffect, useState } from 'react';
import {
AppstoreOutlined,
ClockCircleOutlined,
CopyOutlined,
EditOutlined,
GlobalOutlined,
LockOutlined,
PlusOutlined,
SearchOutlined
} from '@ant-design/icons';
import {
Button,
Input,
Modal,
Form,
Select,
Space,
Tag,
App as AntApp,
Empty,
Tooltip,
Popconfirm,
Spin
} from 'antd';
import { PromptTemplate, PromptTemplateAPI } from '../api';
const CATEGORIES = ['通用', '编程', '写作', '翻译', '分析', '客服', '其他'];
const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: string }> = [
{ key: 'all', label: '全部模板' },
{ key: 'mine', label: '我的沉淀' },
{ key: 'public', label: '公开灵感' }
];
const CATEGORY_STYLES: Record<string, { bg: string; text: string }> = {
: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' },
: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' },
: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' },
: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' },
: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' },
: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' },
: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' }
};
export default function PromptLibraryPage({
onSelect
}: {
onSelect?: (tpl: PromptTemplate) => void;
}) {
const { message } = AntApp.useApp();
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
const [q, setQ] = useState('');
const [list, setList] = useState<PromptTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<PromptTemplate | null>(null);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await PromptTemplateAPI.list({ scope, q });
setList(data);
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ visibility: 'private', category: '通用' });
setEditorOpen(true);
};
const openEdit = (t: PromptTemplate) => {
setEditing(t);
form.setFieldsValue({
title: t.title,
body: t.body,
category: t.category,
visibility: t.visibility
});
setEditorOpen(true);
};
const onSave = async () => {
const v = await form.validateFields();
try {
if (editing) {
await PromptTemplateAPI.update(editing.id, v);
message.success('已更新');
} else {
await PromptTemplateAPI.create(v);
message.success('已创建');
}
setEditorOpen(false);
load();
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
}
};
const onUse = async (t: PromptTemplate) => {
await PromptTemplateAPI.use(t.id).catch(() => {});
if (onSelect) onSelect(t);
else {
navigator.clipboard?.writeText(t.body).then(() => message.success('内容已复制到剪贴板'));
}
};
const onDelete = async (t: PromptTemplate) => {
await PromptTemplateAPI.remove(t.id);
message.success('已删除');
load();
};
const formatDate = (ts: number) =>
new Date(ts).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric'
});
return (
<div className="page-container">
<div
style={{
borderRadius: 24,
padding: '28px 28px 24px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.96) 0%, rgba(236,253,245,0.92) 42%, rgba(240,249,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 18px 50px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 22
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<AppstoreOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}>
Prompt
</h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75, maxWidth: 560 }}>
使
</div>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={openCreate}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
</Button>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.5fr) auto',
gap: 14,
alignItems: 'center'
}}
>
<Input
placeholder="搜索标题、分类或正文..."
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={load}
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
suffix={
q ? (
<Button type="link" size="small" onClick={load} style={{ padding: 0, height: 20 }}>
</Button>
) : null
}
style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }}
allowClear
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{SCOPE_OPTIONS.map((item) => {
const active = scope === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setScope(item.key)}
style={{
border: '1px solid',
borderColor: active ? 'rgba(8, 145, 178, 0.18)' : 'var(--color-border)',
background: active ? 'rgba(8, 145, 178, 0.10)' : 'rgba(255,255,255,0.72)',
color: active ? 'var(--color-brand)' : 'var(--color-text-secondary)',
borderRadius: 999,
padding: '10px 14px',
fontSize: 13,
fontWeight: active ? 600 : 500,
cursor: 'pointer'
}}
>
{item.label}
</button>
);
})}
</div>
</div>
</div>
{loading ? (
<div
style={{
minHeight: 280,
borderRadius: 22,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Spin size="large" />
</div>
) : list.length === 0 ? (
<div
style={{
borderRadius: 22,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '54px 24px'
}}
>
<Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 18
}}
>
{list.map((t) => (
<div
key={t.id}
style={{
borderRadius: 20,
border: '1px solid var(--color-border)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.045)',
padding: 20,
display: 'flex',
flexDirection: 'column',
minHeight: 286
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 16
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag
bordered={false}
style={{
margin: 0,
borderRadius: 999,
paddingInline: 10,
height: 28,
lineHeight: '28px',
fontSize: 12,
fontWeight: 600,
background: CATEGORY_STYLES[t.category || '其他']?.bg || CATEGORY_STYLES['其他'].bg,
color: CATEGORY_STYLES[t.category || '其他']?.text || CATEGORY_STYLES['其他'].text
}}
>
{t.category || '其他'}
</Tag>
<Tag
bordered={false}
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
style={{
margin: 0,
borderRadius: 999,
paddingInline: 10,
height: 28,
lineHeight: '28px',
fontSize: 12,
background: 'var(--color-surface-2)',
color: 'var(--color-text-secondary)'
}}
>
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
</Tag>
</div>
<Tooltip title={onSelect ? '插入到当前对话' : '复制到剪贴板'}>
<Button
type="primary"
icon={<CopyOutlined />}
onClick={() => onUse(t)}
style={{ borderRadius: 12, height: 38, fontWeight: 600 }}
>
{onSelect ? '使用' : '复制'}
</Button>
</Tooltip>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 16,
alignItems: 'flex-start',
marginBottom: 14
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 19,
fontWeight: 700,
color: 'var(--color-text)',
letterSpacing: '-0.02em',
marginBottom: 6
}}
>
{t.title}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
by {t.ownerName || '我'}
</div>
</div>
<div
style={{
flexShrink: 0,
padding: '6px 10px',
borderRadius: 12,
background: 'rgba(15, 23, 42, 0.03)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600
}}
>
使 {t.useCount}
</div>
</div>
<div
style={{
position: 'relative',
borderRadius: 16,
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.92) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
padding: '16px 16px 18px',
minHeight: 132,
overflow: 'hidden'
}}
>
<div
style={{
position: 'absolute',
inset: '0 auto 0 0',
width: 4,
background: CATEGORY_STYLES[t.category || '其他']?.text || CATEGORY_STYLES['其他'].text,
opacity: 0.18
}}
/>
<div
style={{
whiteSpace: 'pre-wrap',
fontSize: 13.5,
color: 'var(--color-text-secondary)',
lineHeight: 1.8,
minHeight: 96,
maxHeight: 130,
overflow: 'hidden'
}}
>
{t.body.slice(0, 220)}
{t.body.length > 220 ? '…' : ''}
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 'auto',
paddingTop: 16,
borderTop: '1px solid var(--color-border)',
fontSize: 12.5,
color: 'var(--color-text-tertiary)'
}}
>
<ClockCircleOutlined />
<span> {formatDate(t.updatedAt)}</span>
<span style={{ flex: 1 }} />
<Space size={2}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEdit(t)} style={{ borderRadius: 10 }}>
</Button>
<Popconfirm title="删除此模板?" onConfirm={() => onDelete(t)}>
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
</Button>
</Popconfirm>
</Space>
</div>
</div>
))}
</div>
)}
<Modal
open={editorOpen}
title={editing ? '编辑模板' : '新建模板'}
onCancel={() => setEditorOpen(false)}
onOk={onSave}
width={680}
okText="保存"
cancelText="取消"
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, max: 60 }]}>
<Input placeholder="例如:技术博客润色助手" />
</Form.Item>
<Form.Item name="category" label="分类">
<Select>
{CATEGORIES.map((c) => (
<Select.Option key={c} value={c}>
{c}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="body"
label="模板正文(支持 {{变量名}} 占位符)"
rules={[{ required: true }]}
tooltip="使用时会以此内容作为消息输入;目前为简单复制,后续可加变量填充表单"
>
<Input.TextArea rows={10} placeholder={'你是一个 {{role}},请帮我...'} />
</Form.Item>
<Form.Item name="visibility" label="可见性">
<Select>
<Select.Option value="private"></Select.Option>
<Select.Option value="public"></Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import { Empty, Spin, App as AntApp } from 'antd';
import { SharedAPI } from '../api';
interface Data {
agent: { name: string; description: string };
session: { id: string; title: string; createdAt: number; updatedAt: number };
messages: { id: string; role: 'user' | 'assistant'; content: string; createdAt: number }[];
}
export default function SharedSessionPage() {
const { token } = useParams();
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>('');
const { message } = AntApp.useApp();
useEffect(() => {
if (!token) return;
SharedAPI.get(token)
.then(setData)
.catch((e) => {
const msg = e?.response?.data?.error ?? e?.message ?? '加载失败';
setErr(msg);
message.error(msg);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
if (err || !data)
return (
<Empty
description={err || '会话不存在或已失效'}
style={{ marginTop: 80 }}
/>
);
return (
<div style={{ minHeight: '100vh', background: 'var(--color-bg)' }}>
<div
style={{
maxWidth: 900,
margin: '0 auto',
padding: '32px 24px',
background: 'var(--color-surface)',
minHeight: '100vh',
boxShadow: 'var(--shadow-lg)'
}}
>
<div
style={{
paddingBottom: 16,
borderBottom: '1px solid var(--color-border)',
marginBottom: 24
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div>
<h1 style={{ margin: 0, fontSize: 24, color: 'var(--color-text)' }}>{data.session.title}</h1>
<div style={{ marginTop: 8, color: 'var(--color-text-secondary)' }}>
🤖 {data.agent.name} · {data.messages.length}
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{new Date(data.session.updatedAt).toLocaleString('zh-CN')}
</span>
</div>
{data.agent.description && (
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-secondary)' }}>
{data.agent.description}
</div>
)}
</div>
{data.messages.map((m) => (
<div
key={m.id}
style={{
marginBottom: 18,
display: 'flex',
flexDirection: 'column',
alignItems: m.role === 'user' ? 'flex-end' : 'flex-start'
}}
>
<div
style={{
fontSize: 12,
color: 'var(--color-text-tertiary)',
marginBottom: 4
}}
>
{m.role === 'user' ? '🧑 用户' : '🤖 助手'} ·{' '}
{new Date(m.createdAt).toLocaleString('zh-CN')}
</div>
<div
style={{
maxWidth: '85%',
padding: '12px 16px',
borderRadius: 14,
background: m.role === 'user' ? 'var(--color-brand-soft)' : 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
boxShadow: m.role === 'assistant' ? 'var(--shadow-xs)' : 'none'
}}
>
{m.role === 'assistant' ? (
<ReactMarkdown>{m.content}</ReactMarkdown>
) : (
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div>
)}
</div>
</div>
))}
<div
style={{
textAlign: 'center',
color: 'var(--color-text-tertiary)',
fontSize: 12,
marginTop: 40
}}
>
Agent Studio · <a href="/login" style={{ color: 'var(--color-brand)' }}> agent</a>
</div>
</div>
</div>
);
}

255
src/pages/StatsPage.tsx Normal file
View File

@ -0,0 +1,255 @@
import { useEffect, useState } from 'react';
import { BarChartOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
import { Card, Empty, App as AntApp, Spin, Tag } from 'antd';
import { Link } from 'react-router-dom';
import { StatsAPI, StatsOverview } from '../api';
export default function StatsPage() {
const { message } = AntApp.useApp();
const [data, setData] = useState<StatsOverview | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
StatsAPI.overview()
.then(setData)
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (loading) return <Spin style={{ marginTop: 80, display: 'block' }} />;
if (!data) return <Empty description="暂无数据" style={{ marginTop: 80 }} />;
const maxDaily = Math.max(1, ...data.daily.map((d) => d.total));
const maxAgent = Math.max(1, ...data.topAgents.map((a) => a.messageCount));
const last7Days = data.daily.slice(-7).reduce((sum, item) => sum + item.total, 0);
const avgSessionMessages = data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
return (
<div className="page-container">
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 44%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap', marginBottom: 20 }}>
<div style={{ maxWidth: 640 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<BarChartOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
使
</div>
</div>
<div
style={{
minWidth: 240,
borderRadius: 20,
padding: '18px 18px 16px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 8 }}> 7 </div>
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{last7Days}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}> {avgSessionMessages} </div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ icon: <RobotOutlined />, label: '智能体数量', value: data.agentCount, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ icon: <LineChartOutlined />, label: '会话总数', value: data.sessionCount, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
{ icon: <MessageOutlined />, label: '消息总数', value: data.messageCount, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--color-text-secondary)', fontSize: 12.5, marginBottom: 10 }}>
<span
style={{
width: 28,
height: 28,
borderRadius: 999,
background: item.tone,
color: item.color,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{item.icon}
</span>
{item.label}
</div>
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</div>
</div>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.35fr) minmax(320px, 0.9fr)', gap: 18 }}>
<Card
title="最近 30 天消息走势"
extra={<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}></Tag>}
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
绿
</div>
{data.daily.length === 0 ? (
<Empty description="近期无对话" />
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 236, padding: '18px 12px 10px' }}>
{data.daily.map((d) => {
const userH = (d.user / maxDaily) * 180;
const aH = (d.assistant / maxDaily) * 180;
return (
<div
key={d.day}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
minWidth: 0
}}
title={`${d.day}\n用户 ${d.user} · 助手 ${d.assistant}`}
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
<div
style={{
width: 9,
height: userH,
background: 'var(--color-brand)',
borderRadius: '6px 6px 0 0'
}}
/>
<div
style={{
width: 9,
height: aH,
background: 'var(--color-success)',
borderRadius: '6px 6px 0 0'
}}
/>
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-tertiary)', marginTop: 6, transform: 'rotate(-45deg)' }}>
{d.day.slice(5)}
</div>
</div>
);
})}
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex', gap: 12 }}>
<span>
<span style={{ display: 'inline-block', width: 10, height: 10, background: 'var(--color-brand)', marginRight: 4, borderRadius: 999 }} />
</span>
<span>
<span style={{ display: 'inline-block', width: 10, height: 10, background: 'var(--color-success)', marginRight: 4, borderRadius: 999 }} />
</span>
</div>
</Card>
<Card title="智能体活跃排行" style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
使
</div>
{data.topAgents.length === 0 ? (
<Empty description="暂无" />
) : (
<div>
{data.topAgents.map((a, index) => (
<div
key={a.id}
style={{
marginBottom: 12,
padding: '12px 14px',
borderRadius: 16,
background: 'linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.9) 100%)',
border: '1px solid rgba(148, 163, 184, 0.12)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8, gap: 10 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.10)',
color: 'var(--color-brand)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 700
}}
>
{index + 1}
</div>
<Link to={`/agents/${a.id}/chat`} style={{ flex: 1, fontWeight: 600, color: 'var(--color-text)' }}>
{a.name}
</Link>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
{a.messageCount}
</Tag>
</div>
<div
style={{
height: 6,
background: 'var(--color-surface-2)',
borderRadius: 3,
overflow: 'hidden'
}}
>
<div
style={{
height: '100%',
width: `${(a.messageCount / maxAgent) * 100}%`,
background: 'var(--gradient-brand)',
transition: 'width 0.4s'
}}
/>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</div>
);
}

395
src/pages/TeamsPage.tsx Normal file
View File

@ -0,0 +1,395 @@
import { useEffect, useState } from 'react';
import {
CopyOutlined,
DeleteOutlined,
MailOutlined,
PlusOutlined,
TeamOutlined,
UserOutlined
} from '@ant-design/icons';
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
import { AuthAPI, Team, TeamAPI } from '../api';
export default function TeamsPage() {
const { message } = AntApp.useApp();
const [list, setList] = useState<Team[]>([]);
const [active, setActive] = useState<Team | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [lastInviteCode, setLastInviteCode] = useState<string | null>(null);
const load = async () => {
const l = await TeamAPI.list();
setList(l);
if (l.length && !active) setActive(await TeamAPI.detail(l[0].id));
};
useEffect(() => {
load();
}, []);
const handleCreate = async (v: any) => {
const t = await TeamAPI.create(v.name);
setCreateOpen(false);
message.success('已创建');
await load();
setActive(await TeamAPI.detail(t.id));
};
const handleInvite = async (v: any) => {
if (!active) return;
const inv = await AuthAPI.createInvite({
teamId: active.id,
email: v.email || undefined,
ttlHours: Number(v.ttlHours) || 168
});
setLastInviteCode(inv.code);
};
return (
<div className="page-container" style={{ maxWidth: 1080 }}>
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 20
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<TeamOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
</div>
</div>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 22, alignItems: 'stretch' }}>
<div
style={{
width: 260,
flexShrink: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
border: '1px solid var(--color-border)',
borderRadius: 22,
padding: 14,
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)'
}}
>
<div style={{ padding: '8px 10px 14px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}></div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}></div>
</div>
{list.length === 0 ? (
<Empty description="还没有团队" />
) : (
<List
dataSource={list}
renderItem={(item) => (
<div
className={`nav-item ${active?.id === item.id ? 'active' : ''}`}
onClick={async () => setActive(await TeamAPI.detail(item.id))}
style={{
padding: '10px 12px',
borderRadius: 14,
cursor: 'pointer',
marginBottom: 6,
background: active?.id === item.id ? 'rgba(8, 145, 178, 0.10)' : 'transparent',
color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-secondary)',
fontWeight: active?.id === item.id ? 600 : 500,
border: active?.id === item.id ? '1px solid rgba(8, 145, 178, 0.16)' : '1px solid transparent'
}}
>
<div style={{ fontSize: 14, marginBottom: 4 }}>{item.name}</div>
<div style={{ fontSize: 12, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
{item.agentCount ?? 0}
</div>
</div>
)}
/>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{active ? (
<Card
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
bodyStyle={{ padding: 22 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 20 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{active.name}</span>
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>{active.myRole}</Tag>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>{active.agentCount ?? 0} </Tag>
</div>
<div style={{ fontSize: 13.5, color: 'var(--color-text-secondary)' }}>
</div>
</div>
<Space>
{(active.myRole === 'owner' || active.myRole === 'admin') && (
<Button icon={<MailOutlined />} onClick={() => setInviteOpen(true)} style={{ borderRadius: 12 }}>
</Button>
)}
{active.myRole === 'owner' && (
<Popconfirm
title="确定删除该团队?团队内的智能体会变成 owner 私有"
onConfirm={async () => {
await TeamAPI.remove(active.id);
message.success('已删除');
setActive(null);
load();
}}
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12 }}>
</Button>
</Popconfirm>
)}
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 20 }}>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
</div>
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 14 }}>
({active.members?.length || 0})
</div>
<List
dataSource={active.members || []}
renderItem={(m) => (
<List.Item
style={{ padding: '14px 0' }}
actions={
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
? [
<Popconfirm
key="kick"
title="移除该成员?"
onConfirm={async () => {
await TeamAPI.removeMember(active.id, m.id);
message.success('已移除');
setActive(await TeamAPI.detail(active.id));
}}
>
<Button size="small" danger style={{ borderRadius: 10 }}>
</Button>
</Popconfirm>
]
: []
}
>
<List.Item.Meta
avatar={
<div
style={{
width: 42,
height: 42,
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.10)',
color: 'var(--color-brand)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<UserOutlined />
</div>
}
title={
<Space>
<span style={{ fontWeight: 600 }}>{m.name}</span>
<Tag
bordered={false}
style={{
background:
m.role === 'owner'
? 'var(--color-warning-soft)'
: m.role === 'admin'
? 'var(--color-info-soft)'
: 'var(--color-surface-2)',
color:
m.role === 'owner'
? 'var(--color-warning)'
: m.role === 'admin'
? 'var(--color-info)'
: 'var(--color-text-secondary)',
borderRadius: 999,
margin: 0
}}
>
{m.role}
</Tag>
</Space>
}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span>{m.email}</span>
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{new Date(m.joinedAt).toLocaleDateString('zh-CN')}
</span>
</div>
}
/>
</List.Item>
)}
/>
</Card>
) : (
<Empty description="选择或创建一个团队" />
)}
</div>
</div>
<Modal
open={createOpen}
title="新建团队"
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnHidden
>
<Form layout="vertical" onFinish={handleCreate}>
<Form.Item name="name" label="团队名称" rules={[{ required: true }]}>
<Input placeholder="如AI 实验小组" autoFocus />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
open={inviteOpen}
title={`📨 邀请加入 ${active?.name}`}
onCancel={() => {
setInviteOpen(false);
setLastInviteCode(null);
}}
footer={null}
destroyOnHidden
>
{lastInviteCode ? (
<div>
<div style={{ marginBottom: 12 }}></div>
<Input.TextArea
value={lastInviteCode}
readOnly
autoSize
style={{ fontFamily: 'monospace', fontSize: 16 }}
/>
<Button
type="default"
icon={<CopyOutlined />}
style={{ marginTop: 12, borderRadius: 10 }}
onClick={() => {
navigator.clipboard?.writeText(lastInviteCode).then(() => message.success('邀请码已复制'));
}}
>
</Button>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 12, marginTop: 8 }}>
</div>
</div>
) : (
<Form layout="vertical" onFinish={handleInvite}>
<Form.Item name="email" label="限定邮箱(可选)">
<Input placeholder="只允许该邮箱使用此邀请码" />
</Form.Item>
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
<Input type="number" placeholder="168 = 7 天" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
)}
</Modal>
</div>
);
}

775
src/pages/WorkflowsPage.tsx Normal file
View File

@ -0,0 +1,775 @@
import { useEffect, useMemo, useState } from 'react';
import {
ApartmentOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
PlusOutlined,
ThunderboltOutlined
} from '@ant-design/icons';
import {
Button,
Card,
Drawer,
Empty,
Form,
Input,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
App as AntApp,
Popconfirm,
Tabs
} from 'antd';
import {
Workflow,
WorkflowAPI,
WorkflowGraph,
WorkflowNode,
WorkflowNodeType,
WorkflowRun,
WorkflowRunDetail,
streamWorkflowRun
} from '../api';
const { Text, Paragraph } = Typography;
const NODE_TYPE_LABEL: Record<WorkflowNodeType, string> = {
agent: '🤖 Agent',
skill: '🛠️ Skill',
http: '🌐 HTTP',
transform: '🔧 Transform',
branch: '🔀 Branch'
};
const STATUS_COLOR: Record<string, string> = {
running: 'blue',
success: 'green',
failed: 'red',
aborted: 'orange',
skipped: 'default'
};
const SAMPLE_GRAPH: WorkflowGraph = {
entry: 'fetch',
variables: { topic: 'Go 1.23 新特性' },
nodes: [
{
id: 'fetch',
type: 'agent',
name: '搜集资料',
config: {
agentId: '',
prompt: '请围绕主题"{{vars.topic}}"列出 5 条最近的关键信息,要点形式。'
},
next: 'summarize'
},
{
id: 'summarize',
type: 'agent',
name: '总结成稿',
config: {
agentId: '',
prompt: '基于以下要点写一段 200 字日报:\n{{steps.fetch.output.text}}'
},
next: ''
}
]
};
export default function WorkflowsPage() {
const { message, modal } = AntApp.useApp();
const [list, setList] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<Workflow | null>(null);
const [runsOpen, setRunsOpen] = useState(false);
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
const load = async () => {
setLoading(true);
try {
const data = await WorkflowAPI.list();
setList(data);
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const onCreate = () => {
setEditing({
id: '',
name: '新工作流',
description: '',
graph: SAMPLE_GRAPH,
scheduleCron: '',
scheduleEnabled: false,
enabled: true,
lastRunAt: 0,
runCount: 0,
createdAt: 0,
updatedAt: 0
});
setEditorOpen(true);
};
const onEdit = (w: Workflow) => {
setEditing(w);
setEditorOpen(true);
};
const onDelete = async (w: Workflow) => {
await WorkflowAPI.remove(w.id);
message.success('已删除');
load();
};
return (
<div className="page-container" style={{ maxWidth: 1400 }}>
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 20
}}
>
<div style={{ maxWidth: 680 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<ApartmentOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
AgentHTTP
</div>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={onCreate}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '定时运行', value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{loading ? null : list.length === 0 ? (
<div
style={{
borderRadius: 22,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '54px 24px'
}}
>
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
{list.map((r) => (
<div
key={r.id}
style={{
borderRadius: 20,
border: '1px solid var(--color-border)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
padding: 20,
minHeight: 308,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
</div>
</div>
<Tag
bordered={false}
style={{
margin: 0,
borderRadius: 999,
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
height: 28,
lineHeight: '28px'
}}
>
{r.enabled ? '已启用' : '已停用'}
</Tag>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.graph?.nodes?.length ?? 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.runCount}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}
</div>
</div>
</div>
<div
style={{
borderRadius: 16,
padding: '16px 16px 14px',
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
marginBottom: 16
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
<ClockCircleOutlined />
</div>
{r.scheduleEnabled && r.scheduleCron ? (
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
cron: {r.scheduleCron}
</Tag>
) : (
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
</Tag>
)}
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
{r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
<Button icon={<PlayCircleOutlined />} onClick={() => { setRunsFor(r); setRunsOpen(true); }} style={{ borderRadius: 12, height: 40 }}>
/
</Button>
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
<Button danger style={{ borderRadius: 12, height: 40 }}>
</Button>
</Popconfirm>
</div>
</div>
))}
</div>
)}
{editing && (
<WorkflowEditor
open={editorOpen}
workflow={editing}
onClose={() => setEditorOpen(false)}
onSaved={() => {
setEditorOpen(false);
load();
}}
/>
)}
{runsFor && (
<RunsDrawer
open={runsOpen}
workflow={runsFor}
onClose={() => setRunsOpen(false)}
/>
)}
</div>
);
}
// ================== 编辑器 ==================
function WorkflowEditor({
open, workflow, onClose, onSaved
}: {
open: boolean;
workflow: Workflow;
onClose: () => void;
onSaved: () => void;
}) {
const { message } = AntApp.useApp();
const [form] = Form.useForm();
const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2));
const [saving, setSaving] = useState(false);
useEffect(() => {
form.setFieldsValue({
name: workflow.name,
description: workflow.description,
scheduleCron: workflow.scheduleCron,
scheduleEnabled: workflow.scheduleEnabled,
enabled: workflow.enabled
});
setGraphText(JSON.stringify(workflow.graph, null, 2));
}, [workflow]);
const onSave = async () => {
const values = await form.validateFields();
let graph: WorkflowGraph;
try {
graph = JSON.parse(graphText);
} catch (e: any) {
message.error('Graph JSON 解析失败:' + e.message);
return;
}
if (!graph.entry || !Array.isArray(graph.nodes)) {
message.error('Graph 必须包含 entry + nodes 数组');
return;
}
setSaving(true);
try {
const payload = { ...values, graph };
if (workflow.id) {
await WorkflowAPI.update(workflow.id, payload);
} else {
await WorkflowAPI.create(payload);
}
message.success('已保存');
onSaved();
} catch (e: any) {
message.error('保存失败:' + (e?.message ?? e));
} finally {
setSaving(false);
}
};
return (
<Drawer
title={workflow.id ? `编辑「${workflow.name}` : '新建工作流'}
width={840}
open={open}
onClose={onClose}
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
extra={
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item label="名称" name="name" rules={[{ required: true, message: '必填' }]}>
<Input />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Space size="large" style={{ marginBottom: 12 }}>
<Form.Item name="enabled" valuePropName="checked" noStyle>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="scheduleEnabled" valuePropName="checked" noStyle>
<Switch checkedChildren="定时开" unCheckedChildren="定时关" />
</Form.Item>
</Space>
<Form.Item
label={
<Space>
<span>Cron </span>
<Text type="secondary" style={{ fontSize: 12 }}>5 "*/30 * * * *"</Text>
</Space>
}
name="scheduleCron"
>
<Input placeholder="例如 0 8 * * 1-5工作日早 8 点)" />
</Form.Item>
</Form>
<NodeQuickAdd
onAdd={(node) => {
try {
const g: WorkflowGraph = JSON.parse(graphText);
g.nodes = [...(g.nodes || []), node];
if (!g.entry) g.entry = node.id;
setGraphText(JSON.stringify(g, null, 2));
} catch {
message.error('Graph JSON 不合法,无法追加');
}
}}
/>
<Card
size="small"
title={<Text strong>Graph JSON</Text>}
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
extra={
<Tooltip title="还原为示例">
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}>
</Button>
</Tooltip>
}
>
<Input.TextArea
rows={20}
value={graphText}
onChange={(e) => setGraphText(e.target.value)}
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
/>
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
<code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code><br />
agent / skill / http / transform / branchbranch <code>condition</code> bool JS next/elseNext
</Paragraph>
</Card>
</Drawer>
);
}
function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) {
const [type, setType] = useState<WorkflowNodeType>('agent');
const [id, setId] = useState('');
return (
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}>
<Space wrap>
<Select
value={type}
style={{ width: 140 }}
onChange={(v) => setType(v as WorkflowNodeType)}
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({
value: k, label
}))}
/>
<Input
placeholder="节点 id如 step1"
value={id}
onChange={(e) => setId(e.target.value)}
style={{ width: 200 }}
/>
<Button
type="primary"
disabled={!id.trim()}
style={{ borderRadius: 10 }}
onClick={() => {
const base: WorkflowNode = {
id: id.trim(),
type,
name: NODE_TYPE_LABEL[type],
config: defaultConfig(type),
next: ''
};
if (type === 'branch') base.elseNext = '';
onAdd(base);
setId('');
}}
>
</Button>
</Space>
</Card>
);
}
function defaultConfig(type: WorkflowNodeType): Record<string, any> {
switch (type) {
case 'agent':
return { agentId: '', prompt: '请帮我处理:{{input.text}}' };
case 'skill':
return { skillId: '', args: {} };
case 'http':
return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null };
case 'transform':
return { code: 'return { value: input.text };' };
case 'branch':
return { condition: 'steps.prev?.output?.value === true' };
}
}
// ================== 运行抽屉 ==================
function RunsDrawer({
open, workflow, onClose
}: {
open: boolean;
workflow: Workflow;
onClose: () => void;
}) {
const { message } = AntApp.useApp();
const [tab, setTab] = useState('run');
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [detail, setDetail] = useState<WorkflowRunDetail | null>(null);
const [streaming, setStreaming] = useState(false);
const [steps, setSteps] = useState<any[]>([]);
const [finalRun, setFinalRun] = useState<any>(null);
const [inputJson, setInputJson] = useState('{}');
const loadRuns = async () => {
try {
const data = await WorkflowAPI.listRuns(workflow.id, 30);
setRuns(data);
} catch (e: any) {
message.error('加载历史失败:' + (e?.message ?? e));
}
};
useEffect(() => {
if (open) loadRuns();
}, [open]);
const onRunStream = () => {
let input: any = undefined;
if (inputJson.trim()) {
try {
input = JSON.parse(inputJson);
} catch (e: any) {
message.error('input JSON 不合法');
return;
}
}
setSteps([]);
setFinalRun(null);
setStreaming(true);
streamWorkflowRun(workflow.id, input, {
onStepStart: (d) => {
setSteps((s) => [...s, { ...d, status: 'running' }]);
},
onStepFinish: (d) => {
setSteps((s) => {
const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running');
if (idx === -1) return [...s, d];
const realIdx = s.length - 1 - idx;
const cp = [...s];
cp[realIdx] = d;
return cp;
});
},
onRunFinish: (d) => {
setStreaming(false);
setFinalRun(d);
loadRuns();
},
onError: (msg) => {
setStreaming(false);
message.error(msg);
}
});
};
const showDetail = async (runId: string) => {
try {
const d = await WorkflowAPI.getRun(runId);
setDetail(d);
} catch (e: any) {
message.error('加载详情失败:' + (e?.message ?? e));
}
};
return (
<Drawer
title={`运行:${workflow.name}`}
width={760}
open={open}
onClose={() => { setDetail(null); onClose(); }}
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
>
<Tabs
activeKey={tab}
onChange={setTab}
items={[
{
key: 'run',
label: '触发',
children: (
<div>
<Form layout="vertical">
<Form.Item label="InputJSON会作为 input.* 注入)">
<Input.TextArea
rows={4}
value={inputJson}
onChange={(e) => setInputJson(e.target.value)}
placeholder='{"text":"hello"}'
/>
</Form.Item>
</Form>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
</Button>
<Button
style={{ borderRadius: 10 }}
onClick={async () => {
try {
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
message.success(`已触发 run=${r.runId}`);
loadRuns();
} catch (e: any) {
message.error('触发失败:' + (e?.message ?? e));
}
}}
>
</Button>
</Space>
{steps.length > 0 && (
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}>
{steps.map((s, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<Space>
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
<Text strong>{s.nodeId}</Text>
<Text type="secondary">{s.nodeType}</Text>
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
</Space>
{s.error && <Paragraph type="danger" style={{ marginBottom: 0 }}>{s.error}</Paragraph>}
{s.output != null && (
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
{JSON.stringify(s.output, null, 2)}
</pre>
)}
</div>
))}
</Card>
)}
{finalRun && (
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
<pre style={{ fontSize: 12, margin: 0 }}>
{JSON.stringify(finalRun, null, 2)}
</pre>
</Card>
)}
</div>
)
},
{
key: 'history',
label: `历史 (${runs.length})`,
children: (
<div>
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}></Button>
<Table<WorkflowRun>
rowKey="id"
size="small"
pagination={false}
dataSource={runs}
columns={[
{ title: '状态', dataIndex: 'status', render: (s) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>, width: 100 },
{ title: '触发', dataIndex: 'trigger', width: 80 },
{ title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 },
{ title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 },
{ title: '', render: (_, r) => <Button size="small" onClick={() => showDetail(r.id)}></Button> }
]}
/>
{detail && (
<Modal
title={`Run ${detail.id}`}
open
onCancel={() => setDetail(null)}
onOk={() => setDetail(null)}
width={680}
>
<p><b></b><Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>{detail.error && <Text type="danger" style={{ marginLeft: 8 }}>{detail.error}</Text>}</p>
<p><b></b>{detail.durationMs} ms</p>
<p><b>Input</b></p>
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre>
<p><b>Steps</b></p>
{detail.steps.map((s) => (
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}>
<Space>
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
<Text strong>{s.nodeId}</Text>
<Text type="secondary">{s.nodeType}</Text>
<Text type="secondary">{s.durationMs} ms</Text>
</Space>
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>}
{s.output != null && (
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(s.output, null, 2)}
</pre>
)}
</Card>
))}
</Modal>
)}
</div>
)
}
]}
/>
</Drawer>
);
}

75
src/skillTemplates.ts Normal file
View File

@ -0,0 +1,75 @@
export const SKILL_TEMPLATES: Record<string, { label: string; content: string }> = {
prompt: {
label: '📝 Prompt 注入式SOP / 工作流)',
content: `---
name: customer_service_sop
description: SOP System Prompt
type: prompt
---
# SOP
##
- 3
-
##
1.
2.
3.
`
},
http: {
label: '🌐 HTTP 工具(外部 API',
content: `---
name: get_weather
description:
type: http
parameters:
type: object
properties:
city:
type: string
description:
required:
- city
handler: https://wttr.in/{{city}}?format=j1
config:
method: GET
timeout: 8000
---
#
function calling
\`{{city}}\` 会被替换为参数值。
`
},
js: {
label: '⚙️ JavaScript 工具(沙箱执行)',
content: `---
name: calc_compound_interest
description: principal, rate (0.05=5%), years
type: js
parameters:
type: object
properties:
principal: { type: number, description: }
rate: { type: number, description: 0.05 }
years: { type: number, description: }
required: [principal, rate, years]
config:
timeout: 3000
---
const { principal, rate, years } = args;
const final = principal * Math.pow(1 + rate, years);
const profit = final - principal;
return {
finalValue: Number(final.toFixed(2)),
profit: Number(profit.toFixed(2)),
summary: \`本金 \${principal}\${years} 年, 年化 \${(rate*100).toFixed(2)}% 复利后变成 \${final.toFixed(2)}\`
};
`
}
};

37
src/store/auth.ts Normal file
View File

@ -0,0 +1,37 @@
import { create } from 'zustand';
import { AuthAPI, AuthUser } from '../api';
interface AuthState {
user: AuthUser | null;
loading: boolean;
/** 启动时调用:从后端拉当前登录态 */
bootstrap: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
register: (p: { email: string; password: string; name: string; inviteCode?: string }) => Promise<void>;
logout: () => Promise<void>;
}
export const useAuth = create<AuthState>((set) => ({
user: null,
loading: true,
bootstrap: async () => {
try {
const u = await AuthAPI.me();
set({ user: u, loading: false });
} catch {
set({ user: null, loading: false });
}
},
login: async (email, password) => {
const u = await AuthAPI.login(email, password);
set({ user: u });
},
register: async (p) => {
const u = await AuthAPI.register(p);
set({ user: u });
},
logout: async () => {
await AuthAPI.logout();
set({ user: null });
}
}));

780
src/styles.css Normal file
View File

@ -0,0 +1,780 @@
:root,
[data-theme='light'] {
--color-bg: #faf9f5;
--color-surface: #ffffff;
--color-surface-2: #f4f2ea;
--color-surface-3: #ece9de;
--color-border: #ebe7da;
--color-border-strong: #d8d2c0;
--color-border-focus: #c2541f;
--color-text: #2a2622;
--color-text-secondary: #6b6660;
--color-text-tertiary: #a09a8e;
--color-brand: #c2541f;
--color-brand-hover: #a64419;
--color-brand-soft: #fdf2ea;
--color-brand-soft-2: #fae3d2;
--color-success: #4f8a4d;
--color-success-soft: #ecf3ec;
--color-warning: #b8782a;
--color-warning-soft: #fdf3e3;
--color-danger: #c0392b;
--color-danger-soft: #fbeae8;
--color-info: #4a6fa5;
--color-info-soft: #ecf1f8;
--shadow-xs: 0 1px 2px rgba(40, 30, 20, 0.04);
--shadow-sm: 0 1px 2px rgba(40, 30, 20, 0.04), 0 1px 3px rgba(40, 30, 20, 0.05);
--shadow-md: 0 2px 4px rgba(40, 30, 20, 0.04), 0 6px 16px rgba(40, 30, 20, 0.06);
--shadow-lg: 0 4px 12px rgba(40, 30, 20, 0.06), 0 16px 40px rgba(40, 30, 20, 0.08);
--shadow-xl: 0 8px 24px rgba(40, 30, 20, 0.08), 0 24px 60px rgba(40, 30, 20, 0.12);
--shadow-focus: 0 0 0 3px rgba(194, 84, 31, 0.18);
--gradient-brand: linear-gradient(135deg, #c2541f, #e07b3e);
--gradient-hero: radial-gradient(1200px 600px at 0% 0%, #fae3d2 0%, transparent 60%),
radial-gradient(900px 500px at 100% 0%, #f0e8d6 0%, transparent 55%),
linear-gradient(180deg, #faf9f5 0%, #f4f2ea 100%);
}
[data-theme='dark'] {
--color-bg: #1a1816;
--color-surface: #221f1c;
--color-surface-2: #2b2824;
--color-surface-3: #36322c;
--color-border: #36322c;
--color-border-strong: #4a443c;
--color-border-focus: #e07b3e;
--color-text: #f3efe6;
--color-text-secondary: #b6afa3;
--color-text-tertiary: #7e7869;
--color-brand: #e07b3e;
--color-brand-hover: #f0935a;
--color-brand-soft: #2d2017;
--color-brand-soft-2: #3a2a1c;
--color-success: #7fb87d;
--color-success-soft: #1e2a1d;
--color-warning: #d49a4a;
--color-warning-soft: #2c2316;
--color-danger: #e07060;
--color-danger-soft: #2a1a17;
--color-info: #8aa9d6;
--color-info-soft: #1a2230;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25), 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.25), 0 6px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.3), 0 16px 40px rgba(0, 0, 0, 0.45);
--shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.4), 0 24px 60px rgba(0, 0, 0, 0.55);
--shadow-focus: 0 0 0 3px rgba(224, 123, 62, 0.25);
--gradient-brand: linear-gradient(135deg, #c2541f, #e07b3e);
--gradient-hero: radial-gradient(1200px 600px at 0% 0%, #3a2a1c 0%, transparent 60%),
radial-gradient(900px 500px at 100% 0%, #2a2620 0%, transparent 55%),
linear-gradient(180deg, #1a1816 0%, #221f1c 100%);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
}
body {
transition: background-color 0.2s ease, color 0.2s ease;
}
.layout-shell {
display: flex;
height: 100vh;
background: var(--color-bg);
}
.main {
flex: 1;
overflow: auto;
background: var(--color-bg);
}
.main-chat {
overflow: hidden;
}
.sidebar {
width: 248px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
color: var(--color-text);
display: flex;
flex-direction: column;
padding: 14px 12px;
}
.sidebar .brand {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
padding: 6px 10px 18px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar .brand .brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--gradient-brand);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: var(--shadow-sm);
}
.sidebar .nav-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-tertiary);
padding: 14px 12px 6px;
}
.sidebar .nav-item {
padding: 7px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1px;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease;
}
.sidebar .nav-item:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
.sidebar .nav-item.active {
background: var(--color-brand-soft);
color: var(--color-brand);
font-weight: 600;
}
.sidebar .nav-item .nav-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.sidebar .kbd {
font-size: 10.5px;
color: var(--color-text-tertiary);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
padding: 1px 5px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
cursor: pointer;
border-radius: 10px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
}
.agent-card {
background: var(--color-surface);
border-radius: 14px;
padding: 20px;
border: 1px solid var(--color-border);
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
box-shadow: var(--shadow-xs);
}
.agent-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--color-border-strong);
}
.agent-card .avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gradient-brand);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 19px;
font-weight: 700;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.agent-card .desc {
color: var(--color-text-secondary);
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
line-height: 1.55;
}
.empty-state {
text-align: center;
padding: 60px 0;
color: var(--color-text-tertiary);
}
.page-hero {
background: var(--gradient-hero);
border-bottom: 1px solid var(--color-border);
padding: 56px 32px 40px;
}
.page-hero .hero-title {
font-size: 32px;
font-weight: 700;
color: var(--color-text);
margin: 0 0 8px;
}
.page-hero .hero-subtitle {
font-size: 15px;
color: var(--color-text-secondary);
margin: 0;
max-width: 640px;
}
.chat-shell {
display: flex;
height: 100vh;
background: var(--color-bg);
}
.chat-side {
width: 280px;
border-right: 1px solid var(--color-border);
padding: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-surface);
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg);
min-width: 0;
}
.chat-header {
height: 60px;
padding: 0 24px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
}
.chat-body {
flex: 1;
overflow-y: auto;
background: var(--color-bg);
}
.chat-body .messages-container {
max-width: 780px;
width: 100%;
margin: 0 auto;
padding: 28px 24px 100px;
}
.bubble {
max-width: 78%;
padding: 14px 18px;
border-radius: 14px;
margin-bottom: 14px;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.65;
font-size: 14.5px;
}
.bubble.user {
background: var(--color-brand-soft);
color: var(--color-text);
margin-left: auto;
border-bottom-right-radius: 5px;
}
.bubble.assistant {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-bottom-left-radius: 5px;
box-shadow: var(--shadow-xs);
}
.bubble.assistant p {
margin: 0 0 10px;
}
.bubble.assistant p:last-child {
margin-bottom: 0;
}
.chat-input-wrapper {
width: 100%;
max-width: 820px;
margin: 0 auto;
padding: 0 24px 24px;
}
.chat-input-card {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 14px 16px 12px;
min-height: 110px;
box-shadow: var(--shadow-sm);
}
.chat-input-card:focus-within {
border-color: var(--color-border-focus);
box-shadow: var(--shadow-focus);
}
.chat-input-textarea {
border: none !important;
box-shadow: none !important;
padding: 10px 4px !important;
font-size: 16px;
line-height: 1.7;
resize: none;
background: transparent !important;
color: var(--color-text) !important;
}
.monica-editor-column {
height: 100%;
overflow-y: auto;
padding: 24px;
}
.monica-section-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 14px;
}
.monica-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.monica-header {
height: 56px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: var(--color-surface);
}
.agent-editor-shell {
background:
radial-gradient(circle at top left, rgba(8, 145, 178, 0.08), transparent 24%),
radial-gradient(circle at top right, rgba(59, 130, 246, 0.07), transparent 22%),
var(--color-bg);
}
.agent-editor-header {
height: 72px;
padding: 0 28px;
border-bottom: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.84);
backdrop-filter: blur(14px);
}
.agent-editor-workbench {
display: flex;
gap: 14px;
padding: 14px;
min-height: 0;
}
.agent-editor-pane {
min-width: 0;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%);
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.045);
overflow: hidden;
}
.agent-editor-pane-body {
height: 100%;
overflow-y: auto;
padding: 22px;
}
.agent-editor-pane-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.agent-editor-pane-title {
font-size: 18px;
font-weight: 700;
color: var(--color-text);
margin: 0 0 6px;
letter-spacing: -0.02em;
}
.agent-editor-pane-subtitle {
font-size: 13px;
line-height: 1.7;
color: var(--color-text-secondary);
margin: 0;
}
.agent-editor-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(8, 145, 178, 0.08);
color: var(--color-brand);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.agent-editor-intro {
border-radius: 18px;
padding: 16px 18px;
background: linear-gradient(135deg, rgba(236,253,245,0.92) 0%, rgba(240,249,255,0.92) 100%);
border: 1px solid rgba(8, 145, 178, 0.12);
margin-bottom: 16px;
}
.agent-editor-intro-title {
font-size: 14px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 6px;
}
.agent-editor-intro-text {
font-size: 12.5px;
line-height: 1.7;
color: var(--color-text-secondary);
}
.agent-editor-surface {
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.88) 100%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.65);
}
.agent-editor-prompt-wrap {
padding: 12px;
}
.agent-editor-prompt {
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(255,255,255,0.72);
}
.agent-editor-preview-shell {
height: 100%;
display: flex;
flex-direction: column;
}
.agent-editor-modal-hero {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 18px;
margin-bottom: 20px;
}
.agent-editor-modal-card {
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.88) 100%);
}
.agent-editor-avatar-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
padding: 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.92) 100%);
border: 1px solid rgba(148, 163, 184, 0.12);
max-height: 280px;
overflow-y: auto;
}
.page-container {
max-width: 1240px;
margin: 0 auto;
padding: 40px 32px 60px;
}
.page-header {
margin-bottom: 32px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text);
margin: 0 0 6px;
}
.page-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0;
}
.create-card {
min-height: 240px;
height: 100%;
border: 1.5px dashed var(--color-border-strong);
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
padding: 20px;
gap: 12px;
cursor: pointer;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background 0.25s ease;
background: var(--color-surface);
box-shadow: var(--shadow-xs);
}
.create-card:hover {
transform: translateY(-2px);
border-color: var(--color-brand);
background: var(--color-brand-soft);
box-shadow: var(--shadow-md);
}
.create-card .create-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
color: var(--color-brand);
font-size: 22px;
border: 1px solid var(--color-border);
}
.theme-toggle {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover {
border-color: var(--color-border-strong);
color: var(--color-text);
background: var(--color-surface-2);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.h-full { height: 100%; }
.w-full { width: 100%; }
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
.object-cover { object-fit: cover; }
.fixed { position: fixed; }
.absolute { position: absolute; }
.relative { position: relative; }
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
.z-100 { z-index: 100; }
.p-4 { padding: 1rem; }
.p-2 { padding: 0.5rem; }
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
.mb-0 { margin-bottom: 0; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-6 { margin-top: 1.5rem; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.rounded-full { border-radius: 9999px; }
.rounded-lg { border-radius: 0.5rem; }
.text-xs { font-size: 0.75rem; }
.text-sm { font-size: 0.875rem; }
.text-lg { font-size: 1.125rem; }
.font-bold { font-weight: 700; }
.font-medium { font-weight: 500; }
.bg-white { background: var(--color-surface); }
.bg-gray-50 { background: var(--color-surface-2); }
.text-gray-400 { color: var(--color-text-tertiary); }
.text-gray-500 { color: var(--color-text-secondary); }
.text-gray-700 { color: var(--color-text); }
.text-gray-800 { color: var(--color-text); }
.border-none { border: none; }
.border-t { border-top: 1px solid var(--color-border); }
.border-l { border-left: 1px solid var(--color-border); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.ant-btn-primary {
box-shadow: 0 1px 2px rgba(194, 84, 31, 0.18) !important;
}
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
color: var(--color-text) !important;
}
.ant-input::placeholder {
color: var(--color-text-tertiary) !important;
}
.ant-input-affix-wrapper:hover,
.ant-input:hover,
.ant-select:hover .ant-select-selector,
.ant-picker:hover {
border-color: var(--color-border-strong) !important;
}
.ant-input-affix-wrapper-focused,
.ant-input:focus,
.ant-select-focused .ant-select-selector,
.ant-picker-focused {
border-color: var(--color-brand) !important;
box-shadow: var(--shadow-focus) !important;
}
.ant-card {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content {
background: var(--color-surface) !important;
}
.ant-modal-title,
.ant-collapse-header {
color: var(--color-text) !important;
}
.ant-divider,
.ant-drawer-header,
.ant-collapse,
.ant-collapse-item,
.ant-collapse-content {
border-color: var(--color-border) !important;
}
.ant-collapse-content {
background: var(--color-surface) !important;
color: var(--color-text) !important;
}
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--color-brand) !important;
}
.ant-tabs-ink-bar {
background: var(--color-brand) !important;
}
[data-theme='dark'] .ant-btn-default {
background: var(--color-surface-2) !important;
border-color: var(--color-border) !important;
color: var(--color-text) !important;
}
[data-theme='dark'] .ant-btn-default:hover {
background: var(--color-surface-3) !important;
border-color: var(--color-border-strong) !important;
}
[data-theme='dark'] .ant-tag {
background: var(--color-surface-2);
border-color: var(--color-border);
color: var(--color-text-secondary);
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}

27
vite.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
// 默认走 Go 后端 :4001要回退 Node 后端就 set VITE_API_TARGET=http://localhost:4000
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const target = env.VITE_API_TARGET || 'http://localhost:4001';
return {
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target,
changeOrigin: true,
// SSE 不要被压缩;保持长连接
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('Accept-Encoding', 'identity');
});
}
}
}
}
};
});