Compare commits
40 Commits
c3f8b598af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71c604411d | |||
| 6fe04f4893 | |||
| d45cc45815 | |||
| f3fbd3876a | |||
| 49ca1f6e1f | |||
| d1ee0bbad2 | |||
| 5d199c4c5c | |||
| 5e6efdaf59 | |||
| 8b6aeb28b1 | |||
| 9733b82c9c | |||
| a9a7216447 | |||
| 8ed857c31c | |||
| 9b41878ae7 | |||
| eacaa5be05 | |||
| 0bab0ecb93 | |||
| db4e5540ad | |||
| a7027c8aaa | |||
| 9316795e4f | |||
| 0a48152e0f | |||
| 6d633eeac4 | |||
| 7f44bc8e32 | |||
| 2d07ac6cd4 | |||
| 67295c22d1 | |||
| dace447a14 | |||
| 3c53bf08bb | |||
| 4b3a9481c6 | |||
| 1712b25748 | |||
| 7e691a8100 | |||
| 35511eb877 | |||
| 143b1e8c4b | |||
| ccf76fea95 | |||
| 85346ce805 | |||
| a34f486d7c | |||
| ea1aff7200 | |||
| afd8b444c7 | |||
| 6aaba810d8 | |||
| 5f76abec8b | |||
| 0905b0302b | |||
| dfe2a5acae | |||
| f5907892bf |
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.worktrees
|
||||||
|
.claude
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
dist
|
||||||
|
docs
|
||||||
|
*.zip
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
13
.env.example
@@ -1,12 +1,9 @@
|
|||||||
DATABASE_URL=postgresql://agentfox:agentfox@localhost:5432/agentfox
|
|
||||||
JWT_SECRET=change-me-to-a-random-secret
|
JWT_SECRET=change-me-to-a-random-secret
|
||||||
JWT_REFRESH_SECRET=change-me-to-another-random-secret
|
JWT_REFRESH_SECRET=change-me-to-another-random-secret
|
||||||
GITHUB_CLIENT_ID=
|
API_KEY_ENCRYPTION_SECRET=change-me-to-a-64-char-hex-string
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
MCP_BASE_URL=http://localhost:3001
|
GITHUB_CLIENT_ID=
|
||||||
SERVER_PORT=3000
|
GITHUB_CLIENT_SECRET=
|
||||||
MCP_PORT=3001
|
OAUTH_CALLBACK_BASE_URL=https://your-domain.com
|
||||||
WEB_PORT=5173
|
FRONTEND_URL=https://your-domain.com
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|||||||
36
.gitignore
vendored
@@ -1,5 +1,41 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|||||||
79
CLAUDE.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Agent Fox is a SaaS product that provides MCP (Model Context Protocol) services for API documentation. It lets LLMs efficiently query OpenAPI docs through multi-level retrieval instead of dumping entire documents into context.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (requires Docker for PostgreSQL)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
|
||||||
|
# Or run services individually (requires local PostgreSQL on port 5432)
|
||||||
|
pnpm dev:server # Express API on :3000
|
||||||
|
pnpm dev:mcp # MCP service on :3001
|
||||||
|
pnpm dev:web # Vite dev server on :5173
|
||||||
|
|
||||||
|
# Database
|
||||||
|
pnpm db:generate # Generate Prisma client after schema changes
|
||||||
|
pnpm db:migrate # Create and apply migrations
|
||||||
|
pnpm db:push # Push schema directly (dev only)
|
||||||
|
|
||||||
|
# Build all packages
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
pnpm monorepo with 4 packages sharing TypeScript config (`tsconfig.base.json`):
|
||||||
|
|
||||||
|
- **`packages/shared`** — Prisma client + shared types. All other packages depend on this. Must be built first (`tsc`) before server/mcp can run.
|
||||||
|
- **`packages/server`** (port 3000) — Express 5 backend API. JWT auth, project CRUD, OpenAPI import/parsing, module/endpoint management.
|
||||||
|
- **`packages/mcp`** (port 3001) — Independent Express process exposing MCP tools via Streamable HTTP transport. Authenticates with project-level API keys (not user JWTs).
|
||||||
|
- **`packages/web`** (port 5173) — React 19 + Vite + TailwindCSS SPA. Proxies `/api` to server in dev mode.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. User imports OpenAPI doc (JSON/YAML/URL) via web UI
|
||||||
|
2. For URL imports, frontend fetches the content (supports localhost/intranet), falling back to server proxy `/api/fetch-spec` for CORS-blocked URLs. Server receives parsed spec objects only.
|
||||||
|
3. Server validates with `@apidevtools/swagger-parser`, dereferences all `$ref`s
|
||||||
|
3. Parses into Module (from tags or path prefixes) and Endpoint records in PostgreSQL
|
||||||
|
4. User gets a project ID + API key
|
||||||
|
5. LLM connects to MCP service at `/mcp/:projectId` with API key
|
||||||
|
6. MCP provides 5 tools for progressive drill-down (overview → modules → endpoints → detail → search)
|
||||||
|
|
||||||
|
### MCP Tools (in `packages/mcp/src/tools/`)
|
||||||
|
|
||||||
|
The 5 tools are designed for minimal token usage per call (~200-2000 tokens each vs 10,000+ for full doc dump):
|
||||||
|
|
||||||
|
- `get_project_overview` — project name, version, module summary
|
||||||
|
- `list_modules` — modules with descriptions
|
||||||
|
- `list_endpoints(moduleId)` — endpoint summaries in a module
|
||||||
|
- `get_endpoint_detail(endpointId)` — full params, request body, responses
|
||||||
|
- `search_endpoints(keyword, moduleId?)` — cross-endpoint keyword search
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
- **API responses**: All endpoints return `{ success: boolean, data?: T, error?: { code, message } }`
|
||||||
|
- **Auth**: User auth uses JWT dual-token (15min access + 7d refresh). MCP auth uses project API keys (`afk_` prefix, bcrypt hashed).
|
||||||
|
- **OAuth**: Google/GitHub via server-side flow. Redirect URL passed through OAuth state store. `validateState()` returns `{ valid, redirect }`. Apple login not yet implemented.
|
||||||
|
- **MCP SDK imports**: Use `@modelcontextprotocol/sdk/server/mcp.js` (not `@modelcontextprotocol/server`). Tool registration uses `server.tool(name, description, zodShape, handler)`.
|
||||||
|
- **Swagger 2.0 + OpenAPI 3.x**: Parser handles both. For Swagger 2, body params are converted to requestBody format.
|
||||||
|
- **Docker dev mode**: Server/MCP use `build` stage + volume mounts for hot reload. Shared must be built inside container before server/mcp start.
|
||||||
|
- **Docker production**: Dockerfiles use `npm install` + global `tsc` (not pnpm — symlinks break on overlay2). `workspace:*` refs are replaced with `file:` refs via `sed`.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
- **Production**: Docker Compose on `ubuntu@43.130.35.66:/opt/1panel/apps/agentfox`. Deploy via `/deploy` skill.
|
||||||
|
- **Port mapping**: web=8088 (80 is OpenResty), server=3000, mcp=3001. PostgreSQL is internal only (no host port).
|
||||||
|
- **`.env`**: Local `.env` is the single source of truth, synced to server on deploy. `.env.example` must stay aligned.
|
||||||
|
- **Nginx**: Web container's nginx proxies `/api/` → server, `/mcp/` → mcp. External Nginx only needs to proxy to port 8088.
|
||||||
|
- **Migrations**: `scripts/migrate-and-start.sh` auto-runs `prisma migrate deploy` before server starts. Always create migrations for schema changes (`prisma migrate dev --name <name> --create-only`), never rely on `db push` alone.
|
||||||
|
- **Domain**: `www.agentfoxapp.com` — not hardcoded anywhere, configured via `OAUTH_CALLBACK_BASE_URL` and `FRONTEND_URL` env vars.
|
||||||
|
|
||||||
|
### Database (Prisma schema at `prisma/schema.prisma`)
|
||||||
|
|
||||||
|
Core models: User → Project → Module → Endpoint. Project stores full dereferenced OpenAPI spec as JSONB. Module tracks its source (tag/path_prefix/manual). Endpoint stores parameters, requestBody, responses as JSONB.
|
||||||
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Agent Fox
|
||||||
|
|
||||||
|
API Documentation MCP Service — 让 LLM 高效检索 API 文档,而非一次性灌入全部内容。
|
||||||
|
|
||||||
|
## 它是什么
|
||||||
|
|
||||||
|
Agent Fox 是一个面向开发者的 SaaS 产品。导入 OpenAPI / Swagger 文档后,它会生成一个 MCP 服务端点,供 Claude、GPT 等大模型通过多级检索按需获取接口信息,最小化 token 消耗。
|
||||||
|
|
||||||
|
**一次典型检索仅需 ~1,300 tokens,而非全量文档的 10,000+ tokens。**
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **导入 OpenAPI 文档** — 支持 OpenAPI 3.x 和 Swagger 2.0,URL 或文件上传
|
||||||
|
- **自动分组** — 按 tags 或 URL 路径前缀自动归类为模块,支持手动调整
|
||||||
|
- **MCP 多级检索** — 5 个工具逐层深入:概览 → 模块 → 接口列表 → 接口详情 → 搜索
|
||||||
|
- **项目管理** — 多项目、独立 API Key、配置一键复制
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | React 19, Vite, TailwindCSS |
|
||||||
|
| 后端 | Express 5, TypeScript, Zod |
|
||||||
|
| MCP | @modelcontextprotocol/sdk, Streamable HTTP |
|
||||||
|
| 数据库 | PostgreSQL 16, Prisma ORM |
|
||||||
|
| 部署 | Docker Compose |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- pnpm
|
||||||
|
- Docker & Docker Compose
|
||||||
|
|
||||||
|
### 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone <repo-url> agent-fox
|
||||||
|
cd agent-fox
|
||||||
|
|
||||||
|
# 复制环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 一键启动(开发模式)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
|
||||||
|
# 首次运行需要执行数据库迁移
|
||||||
|
DATABASE_URL=postgresql://agentfox:agentfox@localhost:5432/agentfox \
|
||||||
|
npx prisma migrate deploy --schema=prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:5173` 使用前端。
|
||||||
|
|
||||||
|
### 生产模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式下 server 容器会自动执行数据库迁移,前端通过 Nginx 在 80 端口提供服务。
|
||||||
|
|
||||||
|
## MCP 接入
|
||||||
|
|
||||||
|
在 Agent Fox 中导入 API 文档后,将生成的配置添加到你的 MCP 客户端:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:3001/mcp/<project-id>",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer <api-key>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 即可通过以下工具按需检索文档:
|
||||||
|
|
||||||
|
| 工具 | 说明 | ~Tokens |
|
||||||
|
|------|------|---------|
|
||||||
|
| `get_project_overview` | 项目概览 + 模块统计 | 200 |
|
||||||
|
| `list_modules` | 模块列表含描述 | 100-300 |
|
||||||
|
| `list_endpoints` | 模块内接口摘要 | 200-500 |
|
||||||
|
| `get_endpoint_detail` | 完整接口详情 | 500-2000 |
|
||||||
|
| `search_endpoints` | 关键字搜索 | 200-500 |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-fox/
|
||||||
|
├── packages/
|
||||||
|
│ ├── web/ # React 前端
|
||||||
|
│ ├── server/ # Express 后端 API (端口 3000)
|
||||||
|
│ ├── mcp/ # MCP 服务 (端口 3001)
|
||||||
|
│ └── shared/ # Prisma Client + 共享类型
|
||||||
|
├── prisma/ # 数据库 Schema + 迁移
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── docker-compose.dev.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
88
docker-compose.dev.yml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/server/Dockerfile
|
||||||
|
target: build
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
npx prisma generate --schema=prisma/schema.prisma &&
|
||||||
|
cd /app/packages/shared && npx tsc &&
|
||||||
|
cd /app &&
|
||||||
|
pnpm --filter @agent-fox/server dev
|
||||||
|
"
|
||||||
|
volumes:
|
||||||
|
- ./packages/shared/src:/app/packages/shared/src
|
||||||
|
- ./packages/shared/tsconfig.json:/app/packages/shared/tsconfig.json
|
||||||
|
- ./packages/server/src:/app/packages/server/src
|
||||||
|
- ./prisma:/app/prisma
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||||
|
JWT_SECRET: dev-secret
|
||||||
|
JWT_REFRESH_SECRET: dev-refresh-secret
|
||||||
|
SERVER_PORT: "3000"
|
||||||
|
NODE_ENV: development
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/mcp/Dockerfile
|
||||||
|
target: build
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
npx prisma generate --schema=prisma/schema.prisma &&
|
||||||
|
cd /app/packages/shared && npx tsc &&
|
||||||
|
cd /app &&
|
||||||
|
pnpm --filter @agent-fox/mcp dev
|
||||||
|
"
|
||||||
|
volumes:
|
||||||
|
- ./packages/shared/src:/app/packages/shared/src
|
||||||
|
- ./packages/shared/tsconfig.json:/app/packages/shared/tsconfig.json
|
||||||
|
- ./packages/mcp/src:/app/packages/mcp/src
|
||||||
|
- ./prisma:/app/prisma
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||||
|
MCP_PORT: "3001"
|
||||||
|
NODE_ENV: development
|
||||||
|
|
||||||
|
docs:
|
||||||
|
build:
|
||||||
|
context: ./docs
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: build
|
||||||
|
command: >
|
||||||
|
sh -c "honkit serve --port 4000"
|
||||||
|
volumes:
|
||||||
|
- ./docs/book.json:/book/book.json
|
||||||
|
- ./docs/SUMMARY.md:/book/SUMMARY.md
|
||||||
|
- ./docs/README.md:/book/README.md
|
||||||
|
- ./docs/faq.md:/book/faq.md
|
||||||
|
- ./docs/introduction:/book/introduction
|
||||||
|
- ./docs/getting-started:/book/getting-started
|
||||||
|
- ./docs/mcp-tools:/book/mcp-tools
|
||||||
|
- ./docs/clients:/book/clients
|
||||||
|
- ./docs/project-management:/book/project-management
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/web/Dockerfile
|
||||||
|
target: build
|
||||||
|
command: >
|
||||||
|
sh -c "pnpm --filter @agent-fox/web exec vite --host 0.0.0.0 --port 5173"
|
||||||
|
volumes:
|
||||||
|
- ./packages/web/src:/app/packages/web/src
|
||||||
|
- ./packages/web/index.html:/app/packages/web/index.html
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
API_URL: http://server:3000
|
||||||
74
docker-compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: agentfox
|
||||||
|
POSTGRES_PASSWORD: agentfox
|
||||||
|
POSTGRES_DB: agentfox
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U agentfox"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/server/Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production}
|
||||||
|
API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||||
|
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||||
|
OAUTH_CALLBACK_BASE_URL: ${OAUTH_CALLBACK_BASE_URL:-}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL:-}
|
||||||
|
SERVER_PORT: "3000"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/mcp/Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||||
|
MCP_PORT: "3001"
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
depends_on:
|
||||||
|
server:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
docs:
|
||||||
|
build:
|
||||||
|
context: ./docs
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/web/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8088}:80"
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
- mcp
|
||||||
|
- docs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
6
docs/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.worktrees
|
||||||
|
.claude
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
*.zip
|
||||||
26
docs/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Stage 1: Build environment with HonKit
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /book
|
||||||
|
|
||||||
|
# Install HonKit globally
|
||||||
|
RUN npm install -g honkit
|
||||||
|
|
||||||
|
# Copy GitBook source files
|
||||||
|
COPY book.json SUMMARY.md README.md faq.md ./
|
||||||
|
COPY introduction/ ./introduction/
|
||||||
|
COPY getting-started/ ./getting-started/
|
||||||
|
COPY mcp-tools/ ./mcp-tools/
|
||||||
|
COPY clients/ ./clients/
|
||||||
|
COPY project-management/ ./project-management/
|
||||||
|
|
||||||
|
# Build static HTML
|
||||||
|
RUN honkit build . /output
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /output /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
39
docs/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# AgentFox 文档
|
||||||
|
|
||||||
|
> **AgentFox** — 为 LLM 而生的 API 文档服务
|
||||||
|
|
||||||
|
AgentFox 将你的 OpenAPI / Swagger 文档转换为 MCP(Model Context Protocol)服务,让 AI 编程助手能够按需查询 API 文档,而不是将整个规范塞进上下文窗口。
|
||||||
|
|
||||||
|
## 为什么选择 AgentFox?
|
||||||
|
|
||||||
|
| 传统方式 | AgentFox |
|
||||||
|
|---------|----------|
|
||||||
|
| 将完整 API 规范粘贴到对话中 | LLM 通过 MCP 按需查询 |
|
||||||
|
| 每次消耗 10,000+ tokens | 每次调用仅 200-2,000 tokens |
|
||||||
|
| 手动复制粘贴,容易遗漏 | 5 个工具自动渐进式下钻 |
|
||||||
|
|
||||||
|
## 快速导航
|
||||||
|
|
||||||
|
- **[什么是 AgentFox](introduction/what-is-agentfox.md)** — 了解产品核心概念
|
||||||
|
- **[MCP 协议介绍](introduction/what-is-mcp.md)** — 了解底层协议
|
||||||
|
- **[快速开始](getting-started/README.md)** — 5 分钟完成首次配置
|
||||||
|
- **[客户端配置](clients/README.md)** — Claude Code、Cursor、Copilot 等配置指南
|
||||||
|
- **[MCP 工具](mcp-tools/README.md)** — 5 个 MCP 工具详细文档
|
||||||
|
- **[常见问题](faq.md)** — FAQ
|
||||||
|
|
||||||
|
## 支持的 AI 工具
|
||||||
|
|
||||||
|
AgentFox 兼容所有支持 MCP 协议的 AI 工具,包括:
|
||||||
|
|
||||||
|
- Claude Desktop / Claude Code
|
||||||
|
- Cursor
|
||||||
|
- GitHub Copilot
|
||||||
|
- Windsurf
|
||||||
|
- Cline
|
||||||
|
- OpenAI Codex CLI
|
||||||
|
- Gemini CLI
|
||||||
|
- 以及更多...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
访问 [www.agentfoxapp.com](https://www.agentfoxapp.com) 免费开始使用。
|
||||||
47
docs/SUMMARY.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 目录
|
||||||
|
|
||||||
|
* [欢迎使用 AgentFox](README.md)
|
||||||
|
|
||||||
|
## 介绍 <a href="#introduction" id="introduction"></a>
|
||||||
|
|
||||||
|
* [什么是 AgentFox](introduction/what-is-agentfox.md)
|
||||||
|
* [MCP 协议介绍](introduction/what-is-mcp.md)
|
||||||
|
|
||||||
|
## 快速开始 <a href="#getting-started" id="getting-started"></a>
|
||||||
|
|
||||||
|
* [概述](getting-started/README.md)
|
||||||
|
* [注册与登录](getting-started/register-and-login.md)
|
||||||
|
* [导入 API 文档](getting-started/import-api-docs.md)
|
||||||
|
* [生成 API Key](getting-started/generate-api-key.md)
|
||||||
|
* [连接第一个 LLM 客户端](getting-started/connect-first-client.md)
|
||||||
|
|
||||||
|
## MCP 工具 <a href="#mcp-tools" id="mcp-tools"></a>
|
||||||
|
|
||||||
|
* [工具概述](mcp-tools/README.md)
|
||||||
|
* [get_project_overview](mcp-tools/get-project-overview.md)
|
||||||
|
* [list_modules](mcp-tools/list-modules.md)
|
||||||
|
* [list_endpoints](mcp-tools/list-endpoints.md)
|
||||||
|
* [get_endpoint_detail](mcp-tools/get-endpoint-detail.md)
|
||||||
|
* [search_endpoints](mcp-tools/search-endpoints.md)
|
||||||
|
|
||||||
|
## 客户端配置 <a href="#clients" id="clients"></a>
|
||||||
|
|
||||||
|
* [配置概述](clients/README.md)
|
||||||
|
* [Claude Desktop](clients/claude-desktop.md)
|
||||||
|
* [Claude Code](clients/claude-code.md)
|
||||||
|
* [Cursor](clients/cursor.md)
|
||||||
|
* [Windsurf](clients/windsurf.md)
|
||||||
|
* [GitHub Copilot](clients/github-copilot.md)
|
||||||
|
* [Cline](clients/cline.md)
|
||||||
|
* [Codex](clients/codex.md)
|
||||||
|
* [其他 MCP 客户端](clients/other-clients.md)
|
||||||
|
|
||||||
|
## 项目管理 <a href="#project-management" id="project-management"></a>
|
||||||
|
|
||||||
|
* [模块管理](project-management/module-management.md)
|
||||||
|
* [重新导入文档](project-management/reimport-docs.md)
|
||||||
|
* [API Key 管理](project-management/api-key-management.md)
|
||||||
|
|
||||||
|
## 其他 <a href="#other" id="other"></a>
|
||||||
|
|
||||||
|
* [常见问题](faq.md)
|
||||||
11
docs/book.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "AgentFox 文档",
|
||||||
|
"description": "AgentFox — 为 LLM 而生的 API 文档服务",
|
||||||
|
"language": "zh-hans",
|
||||||
|
"links": {
|
||||||
|
"sidebar": {
|
||||||
|
"AgentFox 官网": "https://www.agentfoxapp.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": ["-sharing"]
|
||||||
|
}
|
||||||
87
docs/clients/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 客户端配置概述
|
||||||
|
|
||||||
|
AgentFox 使用 MCP 协议的 Streamable HTTP 传输方式,兼容所有支持 MCP 的 AI 工具。
|
||||||
|
|
||||||
|
## 通用配置模板
|
||||||
|
|
||||||
|
所有 MCP 客户端都使用类似的 JSON 配置格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `mcpServers` | MCP 服务器配置的顶层对象 |
|
||||||
|
| `my-api` | 服务器名称,可自定义(建议使用 API 名称,如 `stripe-api`) |
|
||||||
|
| `type` | 传输类型,必须为 `http` |
|
||||||
|
| `url` | MCP 服务 URL,从项目详情的 MCP 标签页复制 |
|
||||||
|
| `headers.Authorization` | 认证头,格式为 `Bearer {你的API-Key}` |
|
||||||
|
|
||||||
|
### 获取配置信息
|
||||||
|
|
||||||
|
1. 登录 [AgentFox 控制台](https://www.agentfoxapp.com)
|
||||||
|
2. 进入项目详情页 → 「MCP」标签
|
||||||
|
3. 复制 MCP 服务 URL
|
||||||
|
4. 从「设置」中获取 API Key
|
||||||
|
|
||||||
|
> **提示**:MCP 标签页中有预生成的配置代码片段,可一键复制。
|
||||||
|
|
||||||
|
## 多项目配置
|
||||||
|
|
||||||
|
如果你有多个项目,可以在 `mcpServers` 中添加多个服务器:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"stripe-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/project-id-1",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer afk_your-api-key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"github-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/project-id-2",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer afk_your-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的客户端
|
||||||
|
|
||||||
|
| 客户端 | 类型 | 配置指南 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| [Claude Desktop](claude-desktop.md) | 桌面应用 | JSON 配置文件 |
|
||||||
|
| [Claude Code](claude-code.md) | CLI 工具 | 项目级或全局配置 |
|
||||||
|
| [Cursor](cursor.md) | AI 编辑器 | 设置或配置文件 |
|
||||||
|
| [Windsurf](windsurf.md) | AI 编辑器 | JSON 配置文件 |
|
||||||
|
| [GitHub Copilot](github-copilot.md) | VS Code 扩展 | VS Code 配置 |
|
||||||
|
| [Cline](cline.md) | VS Code 扩展 | 扩展设置 |
|
||||||
|
| [Codex](codex.md) | CLI 工具 | CLI 参数或配置文件 |
|
||||||
|
| [其他客户端](other-clients.md) | — | 通用配置 |
|
||||||
|
|
||||||
|
## 连接排障
|
||||||
|
|
||||||
|
如果连接失败,请检查:
|
||||||
|
|
||||||
|
1. **URL 是否正确**:确认 projectId 无误
|
||||||
|
2. **API Key 是否有效**:确认 Key 未被轮换
|
||||||
|
3. **网络是否通畅**:确认能访问 `www.agentfoxapp.com`
|
||||||
|
4. **配置格式**:确认 `type` 为 `http`(不是 `sse` 或 `stdio`)
|
||||||
70
docs/clients/claude-code.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Claude Code
|
||||||
|
|
||||||
|
Claude Code 是 Anthropic 的 CLI 编程助手,支持项目级和全局两种 MCP 配置方式。
|
||||||
|
|
||||||
|
## 方式一:项目级配置(推荐)
|
||||||
|
|
||||||
|
在项目根目录创建 `.mcp.json` 文件:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
优点:配置跟随项目,团队成员可共享(注意不要将 API Key 提交到版本控制)。
|
||||||
|
|
||||||
|
## 方式二:全局配置
|
||||||
|
|
||||||
|
编辑 `~/.claude.json`,在顶层添加 `mcpServers`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
全局配置对所有项目生效。
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
启动 Claude Code 后,可以通过以下方式验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 列出已配置的 MCP 服务器
|
||||||
|
claude mcp list
|
||||||
|
```
|
||||||
|
|
||||||
|
或在对话中直接使用:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:帮我看看这个 API 有哪些模块
|
||||||
|
Claude:[调用 get_project_overview]
|
||||||
|
这个 API 包含以下模块:...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
如果使用项目级配置,建议将 `.mcp.json` 添加到 `.gitignore`,避免 API Key 泄露:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo ".mcp.json" >> .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用环境变量(如果客户端支持)来管理 API Key。
|
||||||
77
docs/clients/claude-desktop.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Claude Desktop
|
||||||
|
|
||||||
|
Claude Desktop 是 Anthropic 的桌面客户端,原生支持 MCP 协议。
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 1. 找到配置文件
|
||||||
|
|
||||||
|
| 系统 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
||||||
|
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
||||||
|
|
||||||
|
如果文件不存在,手动创建即可。
|
||||||
|
|
||||||
|
### 2. 编辑配置文件
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将 `{projectId}` 和 `{your-api-key}` 替换为实际值。
|
||||||
|
|
||||||
|
### 3. 重启 Claude Desktop
|
||||||
|
|
||||||
|
保存配置文件后,完全退出并重新打开 Claude Desktop。
|
||||||
|
|
||||||
|
### 4. 验证连接
|
||||||
|
|
||||||
|
在 Claude Desktop 中发送消息:
|
||||||
|
|
||||||
|
```
|
||||||
|
请调用 get_project_overview 查看 API 概览
|
||||||
|
```
|
||||||
|
|
||||||
|
如果看到项目信息,说明配置成功。
|
||||||
|
|
||||||
|
## 多项目配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"stripe-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/project-id-1",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer afk_your-key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"internal-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/project-id-2",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer afk_your-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: 修改配置后需要重启吗?**
|
||||||
|
A: 是的,Claude Desktop 需要重启才能加载新的 MCP 配置。
|
||||||
|
|
||||||
|
**Q: 如何确认 MCP 服务已连接?**
|
||||||
|
A: 在对话中,Claude 会自动识别可用的 MCP 工具。你可以直接要求它调用 AgentFox 的工具。
|
||||||
51
docs/clients/cline.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Cline
|
||||||
|
|
||||||
|
Cline 是一个 VS Code 扩展,提供 AI 编程助手功能,支持 MCP 协议。
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 1. 打开 MCP 设置
|
||||||
|
|
||||||
|
1. 打开 VS Code
|
||||||
|
2. 点击侧栏的 Cline 图标
|
||||||
|
3. 点击 MCP Servers 设置(齿轮图标)
|
||||||
|
|
||||||
|
### 2. 添加 AgentFox 服务器
|
||||||
|
|
||||||
|
在 MCP 配置中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 保存并验证
|
||||||
|
|
||||||
|
保存配置后,Cline 会自动连接 MCP 服务器。你可以在 MCP Servers 列表中看到连接状态。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
在 Cline 的对话中直接使用:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:根据 API 文档帮我实现用户登录功能
|
||||||
|
Cline:让我先查看 API 文档...
|
||||||
|
[调用 search_endpoints keyword="login"]
|
||||||
|
[调用 get_endpoint_detail endpointId="..."]
|
||||||
|
根据文档,登录接口是 POST /auth/login...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
在 Cline 的 MCP Servers 面板中:
|
||||||
|
- 绿色状态表示已连接
|
||||||
|
- 可以查看已注册的 5 个 AgentFox 工具
|
||||||
61
docs/clients/codex.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Codex (OpenAI)
|
||||||
|
|
||||||
|
Codex 是 OpenAI 推出的 CLI 编程助手,支持 MCP 协议。
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 方式一:通过 CLI 参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex --mcp-config '{"my-api":{"type":"http","url":"https://www.agentfoxapp.com/mcp/{projectId}","headers":{"Authorization":"Bearer {your-api-key}"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:通过配置文件
|
||||||
|
|
||||||
|
编辑 `~/.codex/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:项目级配置
|
||||||
|
|
||||||
|
在项目根目录创建 `.codex/mcp.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
启动 Codex 后,在对话中测试:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:调用 get_project_overview 查看 API 信息
|
||||||
|
Codex:[调用 get_project_overview]
|
||||||
|
项目信息如下:...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
使用项目级配置时,建议将 `.codex/` 添加到 `.gitignore`。
|
||||||
56
docs/clients/cursor.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Cursor
|
||||||
|
|
||||||
|
Cursor 是一款 AI 代码编辑器,支持通过 MCP 协议连接外部工具。
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 方式一:通过配置文件
|
||||||
|
|
||||||
|
在项目根目录创建 `.cursor/mcp.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:通过设置界面
|
||||||
|
|
||||||
|
1. 打开 Cursor 设置(`Cmd/Ctrl + ,`)
|
||||||
|
2. 搜索 "MCP"
|
||||||
|
3. 在 MCP Servers 配置区域添加服务器信息
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
配置完成后,在 Cursor 的 AI 对话中(Agent 模式)即可使用 AgentFox 的 MCP 工具:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:帮我调用用户注册接口,参考 API 文档
|
||||||
|
Cursor:[调用 search_endpoints keyword="register"]
|
||||||
|
[调用 get_endpoint_detail endpointId="..."]
|
||||||
|
根据 API 文档,注册接口是 POST /api/users/register...
|
||||||
|
```
|
||||||
|
|
||||||
|
> **提示**:确保使用 Cursor 的 Agent 模式(而非 Ask 模式),Agent 模式才能调用 MCP 工具。
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
1. 打开 Cursor 设置 → MCP 区域
|
||||||
|
2. 检查 AgentFox 服务器状态是否显示为已连接(绿色指示灯)
|
||||||
|
3. 在对话中要求调用 `get_project_overview` 测试
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
建议将 `.cursor/mcp.json` 添加到 `.gitignore`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo ".cursor/mcp.json" >> .gitignore
|
||||||
|
```
|
||||||
71
docs/clients/github-copilot.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# GitHub Copilot
|
||||||
|
|
||||||
|
GitHub Copilot 通过 VS Code 的 MCP 支持连接 AgentFox。
|
||||||
|
|
||||||
|
## 前提条件
|
||||||
|
|
||||||
|
- VS Code 版本 1.99 或更高
|
||||||
|
- GitHub Copilot 扩展已安装并激活
|
||||||
|
- GitHub Copilot Chat 扩展已安装
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 方式一:项目级配置(推荐)
|
||||||
|
|
||||||
|
在项目根目录创建 `.vscode/mcp.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:VS Code MCP 配置的顶层键是 `servers`,不是 `mcpServers`。
|
||||||
|
|
||||||
|
### 方式二:用户级配置
|
||||||
|
|
||||||
|
在 VS Code 的 `settings.json` 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
在 Copilot Chat 中使用 Agent 模式(`@workspace` 或直接对话):
|
||||||
|
|
||||||
|
```
|
||||||
|
你:帮我查看支付相关的 API 端点
|
||||||
|
Copilot:[调用 search_endpoints keyword="payment"]
|
||||||
|
找到以下支付相关端点:...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
1. 打开 VS Code 命令面板(`Cmd/Ctrl + Shift + P`)
|
||||||
|
2. 搜索 "MCP: List Servers"
|
||||||
|
3. 确认 AgentFox 服务器状态为已连接
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
建议将 `.vscode/mcp.json` 添加到 `.gitignore`,或使用 VS Code 的用户级配置来避免 API Key 泄露。
|
||||||
82
docs/clients/other-clients.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 其他 MCP 客户端
|
||||||
|
|
||||||
|
任何支持 MCP 协议 Streamable HTTP 传输的 AI 工具都可以连接 AgentFox。
|
||||||
|
|
||||||
|
## 连接要素
|
||||||
|
|
||||||
|
连接 AgentFox 只需要三个信息:
|
||||||
|
|
||||||
|
| 要素 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| **传输类型** | HTTP(Streamable HTTP) |
|
||||||
|
| **URL** | `https://www.agentfoxapp.com/mcp/{projectId}` |
|
||||||
|
| **认证** | `Authorization: Bearer {your-api-key}` |
|
||||||
|
|
||||||
|
## 通用配置格式
|
||||||
|
|
||||||
|
大多数 MCP 客户端使用 JSON 配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 其他已知兼容客户端
|
||||||
|
|
||||||
|
### Gemini CLI
|
||||||
|
|
||||||
|
Google 的 Gemini CLI 工具支持 MCP 协议。编辑 `~/.gemini/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Antigravity
|
||||||
|
|
||||||
|
Antigravity AI 开发平台支持 MCP。在平台的 MCP 配置中添加 AgentFox 服务器即可。
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
OpenClaw AI 开发平台同样支持 MCP 协议,配置方式类似。
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
如果你的工具需要手动实现 MCP 客户端连接,以下是关键技术参数:
|
||||||
|
|
||||||
|
| 参数 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 协议 | MCP (Model Context Protocol) |
|
||||||
|
| 传输方式 | Streamable HTTP |
|
||||||
|
| HTTP 方法 | POST(发送请求)、GET(SSE 会话恢复)、DELETE(终止会话) |
|
||||||
|
| 会话管理 | 通过 `mcp-session-id` 响应头建立,后续请求携带 |
|
||||||
|
| 认证方式 | HTTP Bearer Token |
|
||||||
|
| Content-Type | `application/json` |
|
||||||
|
|
||||||
|
## 自检清单
|
||||||
|
|
||||||
|
如果连接不成功,请检查:
|
||||||
|
|
||||||
|
- [ ] 传输类型是 `http`(不是 `sse` 或 `stdio`)
|
||||||
|
- [ ] URL 包含正确的 projectId
|
||||||
|
- [ ] API Key 以 `afk_` 开头
|
||||||
|
- [ ] Authorization 头格式为 `Bearer {key}`(注意 Bearer 后有空格)
|
||||||
|
- [ ] 网络可以访问 `www.agentfoxapp.com`
|
||||||
45
docs/clients/windsurf.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Windsurf
|
||||||
|
|
||||||
|
Windsurf 是 Codeium 推出的 AI 代码编辑器,支持 MCP 协议。
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 1. 找到配置文件
|
||||||
|
|
||||||
|
| 系统 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| macOS | `~/.codeium/windsurf/mcp_config.json` |
|
||||||
|
| Windows | `%USERPROFILE%\.codeium\windsurf\mcp_config.json` |
|
||||||
|
| Linux | `~/.codeium/windsurf/mcp_config.json` |
|
||||||
|
|
||||||
|
如果文件不存在,手动创建即可。
|
||||||
|
|
||||||
|
### 2. 编辑配置文件
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 重启 Windsurf
|
||||||
|
|
||||||
|
保存配置后,重启 Windsurf 使配置生效。
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
在 Windsurf 的 Cascade 对话中:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:请调用 get_project_overview 查看我的 API 概览
|
||||||
|
```
|
||||||
|
|
||||||
|
如果返回了项目信息,说明配置成功。
|
||||||
341
docs/deployment-guide.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# AgentFox 生产环境部署指南
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Docker Network │
|
||||||
|
用户请求 │ │
|
||||||
|
────────▶ ┌──────┴──────┐ │
|
||||||
|
│ Nginx │ /api/* │
|
||||||
|
│ (Web) │──────────▶┌─────────┐ │
|
||||||
|
│ :80 │ │ Server │ │
|
||||||
|
│ │ /mcp/* │ (API) │──┐ │
|
||||||
|
│ SPA 静态 │──────▶┌───┤ :3000 │ │ │
|
||||||
|
│ 资源服务 │ │ └─────────┘ │ │
|
||||||
|
└─────────────┘ │ │ │
|
||||||
|
│ ┌─────────┐ │ │
|
||||||
|
└──▶│ MCP │ │ │
|
||||||
|
│ :3001 │──┤ │
|
||||||
|
└─────────┘ │ │
|
||||||
|
▼ │
|
||||||
|
┌────────────┐ ┌───────┐│
|
||||||
|
│ PostgreSQL │ │ Redis ││
|
||||||
|
│ :5432 │ │ :6379 ││
|
||||||
|
└────────────┘ └───────┘│
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 服务 | 镜像 | 端口 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **web** | nginx:alpine | 80 | 前端 SPA + 反向代理(`/api/` → server,`/mcp/` → mcp) |
|
||||||
|
| **server** | node:20-alpine | 3000 | Express API:认证、项目管理、OpenAPI 导入解析 |
|
||||||
|
| **mcp** | node:20-alpine | 3001 | MCP 协议服务:为 LLM 提供 API 文档查询工具 |
|
||||||
|
| **postgres** | postgres:16-alpine | 5432 | 主数据库:用户、项目、模块、端点数据 |
|
||||||
|
| **redis** | redis:7-alpine | 6379 | 会话缓存、速率限制 |
|
||||||
|
|
||||||
|
**请求流向**:用户浏览器 → Nginx(:80) → 静态 SPA 或反代到 Server/MCP → PostgreSQL + Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- Docker Engine 24+ 和 Docker Compose V2
|
||||||
|
- 至少 2GB 内存、10GB 磁盘空间
|
||||||
|
- 一个可访问的域名(用于 HTTPS 和 OAuth 回调)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一步:获取代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> agent-fox
|
||||||
|
cd agent-fox
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二步:配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env` 文件:
|
||||||
|
|
||||||
|
### 必填项
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 先生成 3 个随机密钥
|
||||||
|
openssl rand -hex 32 # → JWT_SECRET
|
||||||
|
openssl rand -hex 32 # → JWT_REFRESH_SECRET
|
||||||
|
openssl rand -hex 32 # → API_KEY_ENCRYPTION_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
JWT_SECRET=<粘贴第1个值>
|
||||||
|
JWT_REFRESH_SECRET=<粘贴第2个值>
|
||||||
|
API_KEY_ENCRYPTION_SECRET=<粘贴第3个值>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **警告**:这三个密钥一旦投入使用,切勿更换。更换 JWT_SECRET 会使所有已登录用户失效,更换 API_KEY_ENCRYPTION_SECRET 会使所有 API Key 不可用。
|
||||||
|
|
||||||
|
### 数据库(可选修改)
|
||||||
|
|
||||||
|
如需自定义数据库密码,同时修改 `docker-compose.yml` 和 `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://myuser:MyStr0ngP@ss@postgres:5432/agentfox
|
||||||
|
```
|
||||||
|
|
||||||
|
对应修改 `docker-compose.yml` 中的 `POSTGRES_USER` / `POSTGRES_PASSWORD`。
|
||||||
|
|
||||||
|
### OAuth 第三方登录(可选)
|
||||||
|
|
||||||
|
不配置则仅支持邮箱密码注册登录。配置方法详见 [OAuth 注册指南](./oauth-setup-guide.md)。
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Google
|
||||||
|
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=GOCSPx-xxx
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
GITHUB_CLIENT_ID=Ov23li...
|
||||||
|
GITHUB_CLIENT_SECRET=xxx
|
||||||
|
|
||||||
|
# Apple
|
||||||
|
APPLE_CLIENT_ID=com.example.agentfox
|
||||||
|
APPLE_TEAM_ID=xxx
|
||||||
|
APPLE_KEY_ID=xxx
|
||||||
|
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----
|
||||||
|
|
||||||
|
# 回调地址(必须与第三方平台注册的一致)
|
||||||
|
OAUTH_CALLBACK_BASE_URL=https://你的域名
|
||||||
|
FRONTEND_URL=https://你的域名
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整 `.env` 示例
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
JWT_SECRET=e0c33a89e22b6ace23a1718d388e2b20f4735b5ab58c5cffe8d31c8fbedb6910
|
||||||
|
JWT_REFRESH_SECRET=28f886e7af5b704b4c45440bd9db41706bf996601f78dd05ca98c7dce358c833
|
||||||
|
API_KEY_ENCRYPTION_SECRET=5917b3fdc14565ede76b118d672dc98cdffb90f8ee16d88e5e6c61eb86e56eb8
|
||||||
|
SERVER_PORT=3000
|
||||||
|
MCP_PORT=3001
|
||||||
|
MCP_BASE_URL=http://mcp:3001
|
||||||
|
OAUTH_CALLBACK_BASE_URL=https://你的域名
|
||||||
|
FRONTEND_URL=https://你的域名
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三步:构建并启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动过程:
|
||||||
|
1. Docker 构建 4 个镜像(约 3-5 分钟)
|
||||||
|
2. PostgreSQL 和 Redis 率先启动并通过健康检查
|
||||||
|
3. Server 启动时自动执行 `prisma migrate deploy` 创建数据库表
|
||||||
|
4. MCP 和 Web 随后启动
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认 5 个服务全部 running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 期望输出:
|
||||||
|
# NAME STATUS
|
||||||
|
# postgres Up (healthy)
|
||||||
|
# redis Up (healthy)
|
||||||
|
# server Up
|
||||||
|
# mcp Up
|
||||||
|
# web Up
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 验证 API 可达
|
||||||
|
curl http://localhost/api/health
|
||||||
|
|
||||||
|
# 验证前端可达
|
||||||
|
curl -s http://localhost | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://服务器IP` 即可使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四步:配置 HTTPS(生产必须)
|
||||||
|
|
||||||
|
### 方案 A:Caddy(推荐,自动 HTTPS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Caddy
|
||||||
|
sudo apt install -y caddy # Debian/Ubuntu
|
||||||
|
# 或 brew install caddy # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 `/etc/caddy/Caddyfile`:
|
||||||
|
|
||||||
|
```
|
||||||
|
你的域名 {
|
||||||
|
reverse_proxy localhost:80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 会自动申请和续期 Let's Encrypt 证书。
|
||||||
|
|
||||||
|
### 方案 B:修改 Nginx 配置
|
||||||
|
|
||||||
|
将 SSL 证书挂载进 web 容器,修改 `packages/web/nginx.conf` 添加 443 监听。
|
||||||
|
|
||||||
|
### 方案 C:云厂商负载均衡
|
||||||
|
|
||||||
|
将 HTTPS 终止放在云厂商 SLB/ALB 上,后端指向服务器的 80 端口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 生产加固清单
|
||||||
|
|
||||||
|
### 数据库安全
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
postgres:
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: agentfox_prod
|
||||||
|
POSTGRES_PASSWORD: <使用 openssl rand -base64 24 生成>
|
||||||
|
ports: [] # 生产环境移除端口映射,仅内部访问
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis 安全
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
command: redis-server --requirepass <密码>
|
||||||
|
ports: [] # 生产环境移除端口映射
|
||||||
|
```
|
||||||
|
|
||||||
|
对应更新环境变量:
|
||||||
|
```env
|
||||||
|
REDIS_URL=redis://:<密码>@redis:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
### 资源限制
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml 各服务添加
|
||||||
|
server:
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: "1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志管理
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 防止日志文件无限增长
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取最新代码
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 重新构建并重启(零停机:先构建再替换)
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# 如果只更新了前端
|
||||||
|
docker compose build web && docker compose up -d web
|
||||||
|
|
||||||
|
# 如果只更新了后端
|
||||||
|
docker compose build server && docker compose up -d server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据备份
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份
|
||||||
|
docker compose exec postgres pg_dump -U agentfox agentfox > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# 恢复
|
||||||
|
cat backup_20260403.sql | docker compose exec -T postgres psql -U agentfox agentfox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 定时备份(cron)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 每天凌晨 3 点备份
|
||||||
|
0 3 * * * cd /path/to/agent-fox && docker compose exec -T postgres pg_dump -U agentfox agentfox | gzip > /backups/agentfox_$(date +\%Y\%m\%d).sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
Redis 数据通过 `redisdata` volume 自动持久化(RDB 快照)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用运维命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === 服务管理 ===
|
||||||
|
docker compose up -d # 启动
|
||||||
|
docker compose down # 停止
|
||||||
|
docker compose restart server # 重启单个服务
|
||||||
|
docker compose logs -f server # 查看日志(实时)
|
||||||
|
docker compose logs --tail 100 # 最近 100 行
|
||||||
|
|
||||||
|
# === 调试 ===
|
||||||
|
docker compose exec server sh # 进入 server 容器
|
||||||
|
docker compose exec postgres psql -U agentfox # 进入数据库
|
||||||
|
docker compose exec redis redis-cli # 进入 Redis
|
||||||
|
|
||||||
|
# === 数据库 ===
|
||||||
|
docker compose exec server npx prisma migrate deploy --schema=prisma/schema.prisma # 手动迁移
|
||||||
|
docker compose exec postgres psql -U agentfox -c "\dt" # 查看表
|
||||||
|
docker compose exec postgres psql -U agentfox -c "SELECT count(*) FROM \"User\"" # 查询用户数
|
||||||
|
|
||||||
|
# === 清理 ===
|
||||||
|
docker compose down -v # ⚠️ 停止并删除所有数据
|
||||||
|
docker system prune -f # 清理无用镜像/缓存
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 现象 | 原因 & 解决 |
|
||||||
|
|------|------------|
|
||||||
|
| `docker compose up` 后 server 一直重启 | 查看 `docker compose logs server`,通常是数据库连接失败。确认 postgres 已 healthy |
|
||||||
|
| 访问页面显示 502 | server 或 mcp 未正常运行。`docker compose ps` 检查状态 |
|
||||||
|
| 登录报 500 | 检查 `JWT_SECRET` 是否已正确配置,`docker compose logs server` 查看详情 |
|
||||||
|
| OAuth 回调报错 | 确认 `.env` 中 `OAUTH_CALLBACK_BASE_URL` 与第三方平台注册的回调地址完全一致(协议+域名) |
|
||||||
|
| MCP 工具连接失败 | 1) 确认 API Key 正确 2) `docker compose logs mcp` 查看错误 3) 确认 MCP URL 格式为 `http(s)://域名/mcp/<projectId>` |
|
||||||
|
| Redis 连接失败 | `docker compose exec redis redis-cli ping` 应返回 PONG |
|
||||||
|
| 数据库迁移失败 | `docker compose exec server npx prisma migrate deploy --schema=prisma/schema.prisma` 手动执行并查看报错 |
|
||||||
|
| 磁盘空间不足 | `docker system prune -f` 清理无用镜像,检查 pg 数据量 |
|
||||||
47
docs/faq.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 常见问题
|
||||||
|
|
||||||
|
## 什么是 MCP?AgentFox 如何使用它?
|
||||||
|
|
||||||
|
MCP(Model Context Protocol)是一个开放标准,让 AI 助手能够连接外部工具和数据源。AgentFox 通过 MCP 工具暴露你的 API 文档,让 Claude Code、Cursor、Copilot 等 AI 编程助手可以按需查询端点详情,而无需将整个规范放入上下文窗口。
|
||||||
|
|
||||||
|
## 支持哪些 OpenAPI 格式?
|
||||||
|
|
||||||
|
AgentFox 支持 OpenAPI 3.x 和 Swagger 2.0 规范。你可以导入 JSON 或 YAML 格式的文档,也可以提供 URL 直接获取。导入时所有 `$ref` 引用会自动解引用。
|
||||||
|
|
||||||
|
## 能减少多少 Token 消耗?
|
||||||
|
|
||||||
|
每次 MCP 工具调用返回约 200-2,000 tokens 的精准信息,相比全量 API 规范的 10,000+ tokens。对于典型的集成任务,这意味着 **80-95%** 的 token 消耗降低。
|
||||||
|
|
||||||
|
## 我的 API 文档安全吗?
|
||||||
|
|
||||||
|
是的。每个账号拥有独立的 API Key(bcrypt 哈希加密,从不以明文存储)。MCP 端点每次请求都需要认证。用户控制台使用 JWT 并自动轮换 token。
|
||||||
|
|
||||||
|
## 兼容哪些 AI 工具?
|
||||||
|
|
||||||
|
任何支持 MCP 协议的工具都可以连接 AgentFox,包括 Claude Desktop、Claude Code、Cursor、Windsurf、GitHub Copilot、Cline、OpenAI Codex CLI、Gemini CLI 等。如果你的工具支持 MCP,就能与 AgentFox 配合使用。
|
||||||
|
|
||||||
|
## 可以私有化部署吗?
|
||||||
|
|
||||||
|
可以。AgentFox 支持云端和私有化部署。企业版包含完整的 Docker Compose 私有化部署支持,以及 SSO 集成和专属技术支持。
|
||||||
|
|
||||||
|
## 如何更新已导入的 API 文档?
|
||||||
|
|
||||||
|
在项目详情的「设置」标签中,点击「重新导入文档」。重新导入会替换所有模块和端点数据,但项目 ID 和 API Key 保持不变,LLM 客户端无需重新配置。详见 [重新导入文档](project-management/reimport-docs.md)。
|
||||||
|
|
||||||
|
## 丢失了 API Key 怎么办?
|
||||||
|
|
||||||
|
如果你是邮箱注册的用户,可以在「设置」中通过密码验证查看已有 Key。如果完全无法找回,可以轮换生成新的 Key(旧 Key 会立即失效)。详见 [API Key 管理](project-management/api-key-management.md)。
|
||||||
|
|
||||||
|
## MCP 连接失败怎么排查?
|
||||||
|
|
||||||
|
1. 确认 `type` 是 `http`(不是 `sse` 或 `stdio`)
|
||||||
|
2. 确认 URL 中的 projectId 正确
|
||||||
|
3. 确认 API Key 有效且格式为 `Bearer {key}`
|
||||||
|
4. 确认网络可以访问 `www.agentfoxapp.com`
|
||||||
|
5. 尝试重启 AI 工具
|
||||||
|
|
||||||
|
详见 [客户端配置概述](clients/README.md) 中的连接排障部分。
|
||||||
|
|
||||||
|
## 一个 API Key 可以用于多个项目吗?
|
||||||
|
|
||||||
|
是的。AgentFox 的 API Key 是账号级别的,一个 Key 对你账号下的所有项目生效。你只需要在 MCP 配置中使用不同的项目 URL 即可。
|
||||||
39
docs/getting-started/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 快速开始
|
||||||
|
|
||||||
|
只需 4 步,即可让你的 AI 编程助手通过 MCP 查询 API 文档。
|
||||||
|
|
||||||
|
## 准备工作
|
||||||
|
|
||||||
|
- 一个浏览器(用于访问 AgentFox 控制台)
|
||||||
|
- 一份 OpenAPI 3.x 或 Swagger 2.0 文档(JSON/YAML 文件或可访问的 URL)
|
||||||
|
- 一个支持 MCP 的 AI 工具(Claude Code、Cursor、Copilot 等)
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
| 步骤 | 说明 | 耗时 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1. [注册与登录](register-and-login.md) | 创建 AgentFox 账号 | 1 分钟 |
|
||||||
|
| 2. [导入 API 文档](import-api-docs.md) | 上传 OpenAPI 文档 | 1 分钟 |
|
||||||
|
| 3. [生成 API Key](generate-api-key.md) | 获取 MCP 认证密钥 | 30 秒 |
|
||||||
|
| 4. [连接 LLM 客户端](connect-first-client.md) | 配置 AI 工具 | 2 分钟 |
|
||||||
|
|
||||||
|
完成后,你的 AI 助手就可以直接查询 API 文档了。
|
||||||
|
|
||||||
|
## 完成效果
|
||||||
|
|
||||||
|
配置完成后,你可以在 AI 工具中这样使用:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:帮我调用 Stripe 的创建支付接口
|
||||||
|
|
||||||
|
AI:让我先查看一下 API 文档...
|
||||||
|
[调用 get_project_overview]
|
||||||
|
[调用 list_endpoints moduleId="payments"]
|
||||||
|
[调用 get_endpoint_detail endpointId="create-charge"]
|
||||||
|
|
||||||
|
根据 API 文档,创建支付的接口是:
|
||||||
|
POST /v1/charges
|
||||||
|
需要参数:amount, currency, source...
|
||||||
|
```
|
||||||
|
|
||||||
|
AI 助手会自动通过 MCP 工具获取所需信息,无需你手动复制文档。
|
||||||
75
docs/getting-started/connect-first-client.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 连接第一个 LLM 客户端
|
||||||
|
|
||||||
|
现在你已经有了项目和 API Key,可以将 AI 工具连接到 AgentFox 的 MCP 服务。
|
||||||
|
|
||||||
|
## 获取 MCP 配置信息
|
||||||
|
|
||||||
|
在项目详情页的「MCP」标签页中,你可以找到:
|
||||||
|
|
||||||
|
1. **MCP 服务 URL**:`https://www.agentfoxapp.com/mcp/{你的项目ID}`
|
||||||
|
2. **配置代码片段**:可一键复制的 JSON 配置
|
||||||
|
|
||||||
|
## 通用配置模板
|
||||||
|
|
||||||
|
所有支持 MCP 的 AI 工具都使用类似的配置格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {your-api-key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将 `{projectId}` 替换为你的项目 ID(从 MCP 标签页复制),将 `{your-api-key}` 替换为你的 API Key。
|
||||||
|
|
||||||
|
## 快速示例:Claude Code
|
||||||
|
|
||||||
|
以 Claude Code 为例,最快的连接方式:
|
||||||
|
|
||||||
|
1. 在项目根目录创建 `.mcp.json` 文件:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-api": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://www.agentfoxapp.com/mcp/你的项目ID",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer 你的API-Key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 重启 Claude Code,即可使用
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
连接成功后,你可以让 AI 助手执行一个简单的测试:
|
||||||
|
|
||||||
|
```
|
||||||
|
你:调用 get_project_overview 查看 API 概览
|
||||||
|
```
|
||||||
|
|
||||||
|
如果返回了项目名称、版本和模块列表,说明连接成功。
|
||||||
|
|
||||||
|
## 各客户端详细配置
|
||||||
|
|
||||||
|
不同 AI 工具的配置方式略有差异,请参考对应的详细指南:
|
||||||
|
|
||||||
|
- [Claude Desktop](../clients/claude-desktop.md)
|
||||||
|
- [Claude Code](../clients/claude-code.md)
|
||||||
|
- [Cursor](../clients/cursor.md)
|
||||||
|
- [Windsurf](../clients/windsurf.md)
|
||||||
|
- [GitHub Copilot](../clients/github-copilot.md)
|
||||||
|
- [Cline](../clients/cline.md)
|
||||||
|
- [Codex (OpenAI)](../clients/codex.md)
|
||||||
|
- [其他客户端](../clients/other-clients.md)
|
||||||
49
docs/getting-started/generate-api-key.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# 生成 API Key
|
||||||
|
|
||||||
|
API Key 是 LLM 客户端连接 AgentFox MCP 服务的认证凭证。你需要先生成一个 API Key 才能使用 MCP 服务。
|
||||||
|
|
||||||
|
## 生成步骤
|
||||||
|
|
||||||
|
1. 进入任意项目的详情页
|
||||||
|
2. 点击页面顶部的「设置」(用户头像旁)
|
||||||
|
3. 在「API Key」区域,点击「生成 API Key」
|
||||||
|
4. 生成的密钥会立即显示
|
||||||
|
|
||||||
|
> **重要**:API Key 仅在生成时显示一次,之后无法再次查看完整密钥。请立即复制并安全保存。
|
||||||
|
|
||||||
|
## API Key 格式
|
||||||
|
|
||||||
|
API Key 以 `afk_` 为前缀,例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
afk_dGhpcyBpcyBhIHNhbXBsZQ
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全说明
|
||||||
|
|
||||||
|
- API Key 使用 bcrypt 哈希加密存储,AgentFox 不保存明文密钥
|
||||||
|
- 一个账号只有一个 API Key,对该账号下所有项目生效
|
||||||
|
- 如果你通过第三方登录(Google/GitHub),需要先在「设置」中设置密码,才能查看或复制 API Key
|
||||||
|
|
||||||
|
## 查看和复制已有 Key
|
||||||
|
|
||||||
|
如果你之前已生成过 API Key:
|
||||||
|
|
||||||
|
1. 打开「设置」
|
||||||
|
2. 在 API Key 区域,点击「查看」或「复制」
|
||||||
|
3. 输入账号密码进行验证
|
||||||
|
4. 验证通过后可查看或复制完整密钥
|
||||||
|
|
||||||
|
## 轮换 API Key
|
||||||
|
|
||||||
|
如果密钥泄露或需要更新:
|
||||||
|
|
||||||
|
1. 打开「设置」→ API Key 区域
|
||||||
|
2. 点击「轮换 API Key」
|
||||||
|
3. 确认操作
|
||||||
|
|
||||||
|
> **注意**:轮换后旧密钥立即失效,所有使用旧密钥的 MCP 客户端需要更新配置。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
拿到 API Key 后,就可以 [连接你的第一个 LLM 客户端](connect-first-client.md) 了。
|
||||||
59
docs/getting-started/import-api-docs.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 导入 API 文档
|
||||||
|
|
||||||
|
AgentFox 支持导入 OpenAPI 3.x 和 Swagger 2.0 格式的 API 文档。
|
||||||
|
|
||||||
|
## 导入方式
|
||||||
|
|
||||||
|
### 方式一:从 URL 导入
|
||||||
|
|
||||||
|
1. 在控制台点击「导入 API 文档」按钮
|
||||||
|
2. 选择「从 URL」标签
|
||||||
|
3. 粘贴 OpenAPI 文档的 URL
|
||||||
|
4. 点击「导入」
|
||||||
|
|
||||||
|
> **提示**:支持 localhost 和内网地址。AgentFox 会先尝试在浏览器端直接获取,如果遇到 CORS 限制,会自动通过服务端代理获取。
|
||||||
|
|
||||||
|
### 方式二:上传文件
|
||||||
|
|
||||||
|
1. 在控制台点击「导入 API 文档」按钮
|
||||||
|
2. 选择「上传文件」标签
|
||||||
|
3. 拖放文件到上传区域,或点击选择文件
|
||||||
|
4. 支持 `.json`、`.yaml`、`.yml` 格式
|
||||||
|
5. 点击「导入」
|
||||||
|
|
||||||
|
## 支持的格式
|
||||||
|
|
||||||
|
| 格式 | 版本 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| OpenAPI | 3.0 / 3.1 | 推荐,功能最完整 |
|
||||||
|
| Swagger | 2.0 | 完整支持,自动转换 body 参数为 requestBody 格式 |
|
||||||
|
|
||||||
|
支持的文件类型:
|
||||||
|
- JSON(`.json`)
|
||||||
|
- YAML(`.yaml` / `.yml`)
|
||||||
|
|
||||||
|
## 导入后会发生什么?
|
||||||
|
|
||||||
|
1. **验证**:AgentFox 使用 `swagger-parser` 验证文档格式
|
||||||
|
2. **解引用**:所有 `$ref` 引用被自动展开
|
||||||
|
3. **分组**:端点按 OpenAPI tags 或 URL 路径前缀自动分组为模块
|
||||||
|
4. **索引**:所有端点的参数、请求体、响应格式被索引存储
|
||||||
|
|
||||||
|
导入成功后,你将看到:
|
||||||
|
- 项目名称(来自文档的 `info.title`)
|
||||||
|
- 解析出的模块数量
|
||||||
|
- 解析出的端点数量
|
||||||
|
|
||||||
|
## 示例:导入 Petstore API
|
||||||
|
|
||||||
|
你可以用以下公开的 OpenAPI 文档来测试:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://petstore3.swagger.io/api/v3/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
导入后将创建包含多个模块(pet、store、user)和对应端点的项目。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
文档导入成功后,接下来 [生成 API Key](generate-api-key.md) 以启用 MCP 服务。
|
||||||
31
docs/getting-started/register-and-login.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 注册与登录
|
||||||
|
|
||||||
|
## 创建账号
|
||||||
|
|
||||||
|
访问 [www.agentfoxapp.com](https://www.agentfoxapp.com),点击右上角的「免费开始」按钮。
|
||||||
|
|
||||||
|
### 方式一:邮箱注册
|
||||||
|
|
||||||
|
1. 点击「注册」
|
||||||
|
2. 填写姓名、邮箱和密码(至少 8 个字符)
|
||||||
|
3. 点击「创建账号」
|
||||||
|
4. 自动跳转到控制台
|
||||||
|
|
||||||
|
### 方式二:第三方登录
|
||||||
|
|
||||||
|
AgentFox 支持以下第三方登录:
|
||||||
|
|
||||||
|
- **Google** — 使用 Google 账号快速登录
|
||||||
|
- **GitHub** — 使用 GitHub 账号快速登录
|
||||||
|
|
||||||
|
点击对应的图标即可跳转到授权页面,授权后自动返回 AgentFox 控制台。
|
||||||
|
|
||||||
|
> **提示**:通过第三方登录的用户,如需查看或复制 API Key,需要先在「设置」中设置密码。
|
||||||
|
|
||||||
|
## 登录
|
||||||
|
|
||||||
|
已有账号的用户,直接在登录页输入邮箱和密码,或使用之前关联的第三方账号登录。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
登录成功后,你将进入控制台。接下来 [导入你的第一份 API 文档](import-api-docs.md)。
|
||||||
69
docs/introduction/what-is-agentfox.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 什么是 AgentFox
|
||||||
|
|
||||||
|
AgentFox 是一个 MCP 驱动的 API 文档服务,让 AI 编程助手(如 Claude Code、Cursor、GitHub Copilot)能够高效地查询你的 API 文档。
|
||||||
|
|
||||||
|
## 解决什么问题?
|
||||||
|
|
||||||
|
当你使用 AI 编程助手调用第三方 API 时,通常需要将 API 文档提供给 LLM。传统做法是将整个 OpenAPI 规范复制粘贴到对话中,这带来了几个问题:
|
||||||
|
|
||||||
|
- **Token 浪费**:一份完整的 OpenAPI 规范可能消耗 10,000-100,000+ tokens,而你可能只需要其中一个端点的信息
|
||||||
|
- **上下文污染**:大量无关信息会降低 LLM 的理解准确度
|
||||||
|
- **手动操作**:每次都需要手动查找、复制文档
|
||||||
|
|
||||||
|
## AgentFox 如何工作?
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ OpenAPI 文档 │───▶│ AgentFox │───▶│ MCP 端点 │
|
||||||
|
│ (JSON/YAML) │ │ 解析 & 索引 │ │ /mcp/:id │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ LLM 按需查询 │
|
||||||
|
│ ~200-2K tokens │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **导入**:上传 OpenAPI 3.x 或 Swagger 2.0 文档(JSON/YAML 文件或 URL)
|
||||||
|
2. **解析**:AgentFox 自动解引用所有 `$ref`,将文档解析为模块和端点
|
||||||
|
3. **生成 MCP 端点**:每个项目获得唯一的 MCP 服务 URL
|
||||||
|
4. **LLM 按需查询**:AI 工具通过 5 个 MCP 工具渐进式获取所需信息
|
||||||
|
|
||||||
|
## 核心优势
|
||||||
|
|
||||||
|
### 渐进式下钻
|
||||||
|
|
||||||
|
LLM 不需要一次获取所有信息。它可以:
|
||||||
|
- 先查看项目概览,了解有哪些模块
|
||||||
|
- 再查看特定模块的端点列表
|
||||||
|
- 最后获取具体端点的完整参数和响应格式
|
||||||
|
|
||||||
|
### Token 高效
|
||||||
|
|
||||||
|
| 操作 | Token 消耗 |
|
||||||
|
|------|-----------|
|
||||||
|
| `get_project_overview` | ~200 tokens |
|
||||||
|
| `list_modules` | ~100-300 tokens |
|
||||||
|
| `list_endpoints` | ~200-500 tokens |
|
||||||
|
| `get_endpoint_detail` | ~500-2,000 tokens |
|
||||||
|
| `search_endpoints` | ~200-500 tokens |
|
||||||
|
| **全量 OpenAPI 规范** | **10,000-100,000+ tokens** |
|
||||||
|
|
||||||
|
典型的 API 集成任务只需 2-3 次工具调用(约 1,300 tokens),相比全量规范节省 **80-95%** 的 token 消耗。
|
||||||
|
|
||||||
|
### 全规范支持
|
||||||
|
|
||||||
|
- OpenAPI 3.0 / 3.1
|
||||||
|
- Swagger 2.0
|
||||||
|
- JSON 和 YAML 格式
|
||||||
|
- 所有 `$ref` 引用自动解引用
|
||||||
|
|
||||||
|
### 一键导入
|
||||||
|
|
||||||
|
粘贴 URL 或上传文件,API 文档即时解析并索引。支持从 localhost 和内网地址获取文档。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [了解 MCP 协议](what-is-mcp.md)
|
||||||
|
- [快速开始](../getting-started/README.md)
|
||||||
65
docs/introduction/what-is-mcp.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# MCP 协议介绍
|
||||||
|
|
||||||
|
MCP(Model Context Protocol)是由 Anthropic 推出的开放标准协议,用于连接 AI 助手与外部工具和数据源。
|
||||||
|
|
||||||
|
## 什么是 MCP?
|
||||||
|
|
||||||
|
你可以把 MCP 理解为 AI 世界的 "USB 接口":
|
||||||
|
|
||||||
|
- **统一标准**:一个协议连接所有 AI 工具和数据源
|
||||||
|
- **双向通信**:AI 助手可以调用工具,也可以接收数据
|
||||||
|
- **安全可控**:每个连接都有明确的权限和认证机制
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┐ MCP 协议 ┌────────────┐
|
||||||
|
│ │◀────────────────────────▶│ │
|
||||||
|
│ MCP 客户端 │ Streamable HTTP │ MCP 服务器 │
|
||||||
|
│ (AI 工具) │ │ (数据源/工具) │
|
||||||
|
│ │ Tools / Resources │ │
|
||||||
|
└────────────┘ └────────────┘
|
||||||
|
|
||||||
|
Claude Code AgentFox
|
||||||
|
Cursor 数据库
|
||||||
|
Copilot 文件系统
|
||||||
|
... ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP 的核心概念
|
||||||
|
|
||||||
|
### Tools(工具)
|
||||||
|
|
||||||
|
MCP 服务器可以向客户端暴露一组工具(函数),AI 助手可以调用这些工具来获取信息或执行操作。
|
||||||
|
|
||||||
|
AgentFox 提供 5 个工具:
|
||||||
|
- `get_project_overview` — 获取项目概览
|
||||||
|
- `list_modules` — 列出所有模块
|
||||||
|
- `list_endpoints` — 列出模块中的端点
|
||||||
|
- `get_endpoint_detail` — 获取端点详情
|
||||||
|
- `search_endpoints` — 搜索端点
|
||||||
|
|
||||||
|
### Transport(传输层)
|
||||||
|
|
||||||
|
MCP 支持多种传输方式。AgentFox 使用 **Streamable HTTP** 传输,这意味着:
|
||||||
|
- 无需安装任何本地插件
|
||||||
|
- 通过标准 HTTP 请求通信
|
||||||
|
- 支持远程连接,适合云端部署
|
||||||
|
|
||||||
|
## AgentFox 如何使用 MCP?
|
||||||
|
|
||||||
|
AgentFox 是一个 **MCP 服务器**,它:
|
||||||
|
|
||||||
|
1. 接收你的 OpenAPI 文档并解析索引
|
||||||
|
2. 为每个项目生成唯一的 MCP 端点 URL
|
||||||
|
3. 通过 5 个 MCP 工具提供按需查询能力
|
||||||
|
4. AI 工具(MCP 客户端)连接到这个端点,即可按需查询 API 文档
|
||||||
|
|
||||||
|
你的 AI 工具(如 Claude Code、Cursor)就是 **MCP 客户端**,它们通过 MCP 协议连接 AgentFox,获取所需的 API 信息。
|
||||||
|
|
||||||
|
## 了解更多
|
||||||
|
|
||||||
|
- [MCP 官方文档](https://modelcontextprotocol.io/)
|
||||||
|
- [MCP 规范](https://spec.modelcontextprotocol.io/)
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [快速开始](../getting-started/README.md) — 5 分钟完成首次配置
|
||||||
48
docs/mcp-tools/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# MCP 工具概述
|
||||||
|
|
||||||
|
AgentFox 提供 5 个 MCP 工具,采用渐进式下钻设计,让 LLM 按需获取精确信息。
|
||||||
|
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
传统方式是将完整 API 规范一次性提供给 LLM(10,000+ tokens)。AgentFox 将信息分层,LLM 按需逐层获取:
|
||||||
|
|
||||||
|
```
|
||||||
|
get_project_overview ← 项目级:名称、版本、模块摘要(~200 tokens)
|
||||||
|
│
|
||||||
|
├── list_modules ← 模块级:模块描述和端点数量(~100-300 tokens)
|
||||||
|
│ │
|
||||||
|
│ └── list_endpoints ← 端点列表:方法、路径、摘要(~200-500 tokens)
|
||||||
|
│ │
|
||||||
|
│ └── get_endpoint_detail ← 端点详情:参数、请求体、响应(~500-2000 tokens)
|
||||||
|
│
|
||||||
|
└── search_endpoints ← 关键词搜索:跨模块搜索端点(~200-500 tokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推荐调用流程
|
||||||
|
|
||||||
|
对于典型的 API 集成任务,LLM 通常会按以下顺序调用:
|
||||||
|
|
||||||
|
1. **`get_project_overview`** — 了解项目结构和可用模块
|
||||||
|
2. **`list_endpoints`** — 浏览目标模块的端点列表(或用 `search_endpoints` 搜索)
|
||||||
|
3. **`get_endpoint_detail`** — 获取目标端点的完整信息
|
||||||
|
|
||||||
|
大多数任务只需要 2-3 次调用,总共约 1,000-1,500 tokens。
|
||||||
|
|
||||||
|
## Token 消耗对比
|
||||||
|
|
||||||
|
| 工具 | 平均 Token 消耗 | 说明 |
|
||||||
|
|------|----------------|------|
|
||||||
|
| `get_project_overview` | ~200 | 最轻量,建议首先调用 |
|
||||||
|
| `list_modules` | ~100-300 | 按模块数量线性增长 |
|
||||||
|
| `list_endpoints` | ~200-500 | 按端点数量线性增长 |
|
||||||
|
| `get_endpoint_detail` | ~500-2,000 | 取决于参数和响应 schema 复杂度 |
|
||||||
|
| `search_endpoints` | ~200-500 | 最多返回 20 条结果 |
|
||||||
|
| **全量 OpenAPI 规范** | **10,000-100,000+** | 传统方式 |
|
||||||
|
|
||||||
|
## 工具列表
|
||||||
|
|
||||||
|
- [`get_project_overview`](get-project-overview.md) — 获取项目概览
|
||||||
|
- [`list_modules`](list-modules.md) — 列出所有模块
|
||||||
|
- [`list_endpoints`](list-endpoints.md) — 列出模块中的端点
|
||||||
|
- [`get_endpoint_detail`](get-endpoint-detail.md) — 获取端点完整详情
|
||||||
|
- [`search_endpoints`](search-endpoints.md) — 按关键词搜索端点
|
||||||
92
docs/mcp-tools/get-endpoint-detail.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# get_endpoint_detail
|
||||||
|
|
||||||
|
获取端点的完整详细信息,包括参数、请求体和响应格式。
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `endpointId` | string | 是 | 端点 ID,可从 `list_endpoints` 或 `search_endpoints` 获取 |
|
||||||
|
|
||||||
|
## 返回结果
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ep_001",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/charges",
|
||||||
|
"summary": "Create a charge",
|
||||||
|
"description": "Creates a new charge object. If the charge fails, the API returns an error.",
|
||||||
|
"operationId": "createCharge",
|
||||||
|
"moduleName": "Payments",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Idempotency-Key",
|
||||||
|
"in": "header",
|
||||||
|
"required": false,
|
||||||
|
"schema": { "type": "string" },
|
||||||
|
"description": "Unique key for idempotent requests"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["amount", "currency"],
|
||||||
|
"properties": {
|
||||||
|
"amount": { "type": "integer", "description": "Amount in cents" },
|
||||||
|
"currency": { "type": "string", "description": "Three-letter ISO currency code" },
|
||||||
|
"source": { "type": "string", "description": "Payment source token" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Charge created successfully",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"amount": { "type": "integer" },
|
||||||
|
"status": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deprecated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 返回字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | string | 端点 ID |
|
||||||
|
| `method` | string | HTTP 方法 |
|
||||||
|
| `path` | string | API 路径 |
|
||||||
|
| `summary` | string | 端点摘要 |
|
||||||
|
| `description` | string | 端点详细描述 |
|
||||||
|
| `operationId` | string | 操作标识符 |
|
||||||
|
| `moduleName` | string | 所属模块名称 |
|
||||||
|
| `parameters` | array | URL/查询/头部/Cookie 参数列表(原始 OpenAPI schema) |
|
||||||
|
| `requestBody` | object | 请求体规范 |
|
||||||
|
| `responses` | object | HTTP 响应规范(按状态码分组) |
|
||||||
|
| `deprecated` | boolean | 是否已弃用 |
|
||||||
|
|
||||||
|
## Token 消耗
|
||||||
|
|
||||||
|
约 **500-2,000 tokens**,取决于参数数量和响应 schema 的复杂程度。这是 token 消耗最多的工具,但也是信息最丰富的。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
- 需要了解如何调用某个具体端点
|
||||||
|
- 查看请求参数的类型、是否必填、描述
|
||||||
|
- 查看响应格式以便解析返回数据
|
||||||
52
docs/mcp-tools/get-project-overview.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# get_project_overview
|
||||||
|
|
||||||
|
获取项目的基本信息和模块摘要。这是推荐的第一个调用工具。
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
无参数。
|
||||||
|
|
||||||
|
## 返回结果
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Stripe API",
|
||||||
|
"description": "The Stripe REST API",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"baseUrl": "https://api.stripe.com",
|
||||||
|
"totalEndpoints": 247,
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "mod_abc123",
|
||||||
|
"name": "Payments",
|
||||||
|
"endpointCount": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mod_def456",
|
||||||
|
"name": "Customers",
|
||||||
|
"endpointCount": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 返回字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | string | 项目名称(来自 OpenAPI 的 `info.title`) |
|
||||||
|
| `description` | string | 项目描述 |
|
||||||
|
| `version` | string | OpenAPI 规范版本 |
|
||||||
|
| `baseUrl` | string \| null | API 基础 URL |
|
||||||
|
| `totalEndpoints` | number | 端点总数 |
|
||||||
|
| `modules` | array | 模块列表(含 id、名称和端点数量) |
|
||||||
|
|
||||||
|
## Token 消耗
|
||||||
|
|
||||||
|
约 **200 tokens**,是最轻量的工具。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
- 初次连接时了解 API 的整体结构
|
||||||
|
- 查看有哪些模块可供探索
|
||||||
|
- 获取模块 ID 以供后续调用 `list_endpoints`
|
||||||
59
docs/mcp-tools/list-endpoints.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# list_endpoints
|
||||||
|
|
||||||
|
列出指定模块中的所有端点摘要信息。
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `moduleId` | string | 是 | 模块 ID,可从 `get_project_overview` 或 `list_modules` 获取 |
|
||||||
|
|
||||||
|
## 返回结果
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ep_001",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/charges",
|
||||||
|
"summary": "Create a charge",
|
||||||
|
"deprecated": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ep_002",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/v1/charges/{id}",
|
||||||
|
"summary": "Retrieve a charge",
|
||||||
|
"deprecated": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ep_003",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/refunds",
|
||||||
|
"summary": "Create a refund",
|
||||||
|
"deprecated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 返回字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | string | 端点 ID,用于 `get_endpoint_detail` |
|
||||||
|
| `method` | string | HTTP 方法(GET、POST、PUT、DELETE 等) |
|
||||||
|
| `path` | string | API 路径 |
|
||||||
|
| `summary` | string | 端点简短描述 |
|
||||||
|
| `deprecated` | boolean | 是否已弃用 |
|
||||||
|
|
||||||
|
结果按路径字母排序,然后按 HTTP 方法排序。
|
||||||
|
|
||||||
|
## Token 消耗
|
||||||
|
|
||||||
|
约 **200-500 tokens**,取决于模块中的端点数量。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
- 浏览特定模块中的所有端点
|
||||||
|
- 找到目标端点的 ID,用于调用 `get_endpoint_detail`
|
||||||
|
- 了解 API 提供了哪些操作
|
||||||
49
docs/mcp-tools/list-modules.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# list_modules
|
||||||
|
|
||||||
|
列出项目中的所有模块及其描述信息。
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
无参数。
|
||||||
|
|
||||||
|
## 返回结果
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "mod_abc123",
|
||||||
|
"name": "Payments",
|
||||||
|
"description": "Manage charges, refunds, and payment intents",
|
||||||
|
"endpointCount": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mod_def456",
|
||||||
|
"name": "Customers",
|
||||||
|
"description": "Create and manage customer records",
|
||||||
|
"endpointCount": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 返回字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | string | 模块 ID,用于 `list_endpoints` 和 `search_endpoints` |
|
||||||
|
| `name` | string | 模块名称 |
|
||||||
|
| `description` | string | 模块描述 |
|
||||||
|
| `endpointCount` | number | 模块中的端点数量 |
|
||||||
|
|
||||||
|
## Token 消耗
|
||||||
|
|
||||||
|
约 **100-300 tokens**,取决于模块数量。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
- 需要查看模块的详细描述(`get_project_overview` 不包含描述)
|
||||||
|
- 决定要探索哪个模块时
|
||||||
|
- 需要获取模块 ID 用于后续调用
|
||||||
|
|
||||||
|
## 与 get_project_overview 的区别
|
||||||
|
|
||||||
|
`get_project_overview` 返回的模块列表只包含 id、名称和端点数量,不包含描述。如果需要模块描述来判断探索哪个模块,使用 `list_modules`。
|
||||||
64
docs/mcp-tools/search-endpoints.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# search_endpoints
|
||||||
|
|
||||||
|
按关键词搜索端点。支持跨模块搜索,也可以限定在特定模块内。
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `keyword` | string | 是 | 搜索关键词 |
|
||||||
|
| `moduleId` | string | 否 | 限定搜索范围的模块 ID,不填则搜索所有模块 |
|
||||||
|
|
||||||
|
## 搜索范围
|
||||||
|
|
||||||
|
关键词会匹配以下字段(不区分大小写):
|
||||||
|
- 端点路径(`path`)
|
||||||
|
- 端点摘要(`summary`)
|
||||||
|
- 端点描述(`description`)
|
||||||
|
- 操作标识符(`operationId`)
|
||||||
|
|
||||||
|
## 返回结果
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ep_001",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/charges",
|
||||||
|
"summary": "Create a charge",
|
||||||
|
"moduleName": "Payments",
|
||||||
|
"deprecated": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ep_005",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/payment_intents",
|
||||||
|
"summary": "Create a PaymentIntent",
|
||||||
|
"moduleName": "Payments",
|
||||||
|
"deprecated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 返回字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | string | 端点 ID,可传给 `get_endpoint_detail` |
|
||||||
|
| `method` | string | HTTP 方法 |
|
||||||
|
| `path` | string | API 路径 |
|
||||||
|
| `summary` | string | 端点摘要 |
|
||||||
|
| `moduleName` | string | 所属模块名称 |
|
||||||
|
| `deprecated` | boolean | 是否已弃用 |
|
||||||
|
|
||||||
|
最多返回 **20 条**结果,按路径和方法排序。
|
||||||
|
|
||||||
|
## Token 消耗
|
||||||
|
|
||||||
|
约 **200-500 tokens**。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
- 不知道端点在哪个模块,通过关键词搜索
|
||||||
|
- 快速定位与某个功能相关的端点
|
||||||
|
- 模糊搜索,如搜索 "user" 找到所有用户相关端点
|
||||||
9
docs/nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
docs/oauth-setup-guide.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# OAuth 第三方登录注册指南
|
||||||
|
|
||||||
|
## 1. Google OAuth
|
||||||
|
|
||||||
|
**前往**: [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 创建项目(或选已有项目)
|
||||||
|
2. 左侧菜单 → "OAuth consent screen" → 选 External → 填写应用名称(AgentFox)、用户支持邮箱
|
||||||
|
3. 左侧菜单 → "Credentials" → "Create Credentials" → "OAuth 2.0 Client ID"
|
||||||
|
4. 应用类型选 **Web application**
|
||||||
|
5. 名称填 `AgentFox Web`
|
||||||
|
6. "Authorized redirect URIs" 添加:
|
||||||
|
- 开发:`http://localhost:3000/api/auth/oauth/google/callback`
|
||||||
|
- 生产:`https://你的域名/api/auth/oauth/google/callback`
|
||||||
|
7. 创建后拿到 **Client ID** 和 **Client Secret**
|
||||||
|
|
||||||
|
**写入 `.env`**:
|
||||||
|
```
|
||||||
|
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=GOCSPx-xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. GitHub OAuth
|
||||||
|
|
||||||
|
**前往**: [GitHub Developer Settings](https://github.com/settings/developers)
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. "OAuth Apps" → "New OAuth App"
|
||||||
|
2. 填写:
|
||||||
|
- Application name: `AgentFox`
|
||||||
|
- Homepage URL: `http://localhost:5173`(开发)
|
||||||
|
- Authorization callback URL: `http://localhost:3000/api/auth/oauth/github/callback`
|
||||||
|
3. 创建后拿到 **Client ID**,点击 "Generate a new client secret" 拿到 **Client Secret**
|
||||||
|
|
||||||
|
**写入 `.env`**:
|
||||||
|
```
|
||||||
|
GITHUB_CLIENT_ID=Ov23li...
|
||||||
|
GITHUB_CLIENT_SECRET=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
> 生产环境需要再创建一个 OAuth App,callback URL 改为生产域名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Apple Sign In
|
||||||
|
|
||||||
|
> 需要 **Apple Developer Program** 付费账号($99/年)。如果暂时没有,可以先跳过,按钮已在前端显示但会报错提示。
|
||||||
|
|
||||||
|
**前往**: [Apple Developer - Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources)
|
||||||
|
|
||||||
|
### 3a. 注册 App ID
|
||||||
|
1. "Identifiers" → "+" → 选 "App IDs" → "App"
|
||||||
|
2. Description: `AgentFox`
|
||||||
|
3. Bundle ID: `com.agentfox.web`(Explicit)
|
||||||
|
4. 勾选 "Sign In with Apple" → Continue → Register
|
||||||
|
|
||||||
|
### 3b. 创建 Services ID
|
||||||
|
1. "Identifiers" → "+" → 选 "Services IDs"
|
||||||
|
2. Description: `AgentFox Web Login`
|
||||||
|
3. Identifier: `com.agentfox.web.login` ← 这就是 **APPLE_CLIENT_ID**
|
||||||
|
4. 勾选 "Sign In with Apple" → Configure:
|
||||||
|
- Primary App ID: 选上面创建的 App ID
|
||||||
|
- Domains: `你的域名`(开发时用 ngrok)
|
||||||
|
- Return URLs: `https://你的域名/api/auth/oauth/apple/callback`
|
||||||
|
5. Save → Continue → Register
|
||||||
|
|
||||||
|
### 3c. 创建 Key
|
||||||
|
1. "Keys" → "+" → 名称 `AgentFox Auth Key`
|
||||||
|
2. 勾选 "Sign In with Apple" → Configure → 选 Primary App ID → Save
|
||||||
|
3. Continue → Register → **下载 .p8 文件**(只能下载一次!)
|
||||||
|
4. 记下 **Key ID**
|
||||||
|
|
||||||
|
### 3d. 找到 Team ID
|
||||||
|
1. 右上角账户名 → "Membership details"
|
||||||
|
2. 记下 **Team ID**
|
||||||
|
|
||||||
|
**写入 `.env`**:
|
||||||
|
```
|
||||||
|
APPLE_CLIENT_ID=com.agentfox.web.login
|
||||||
|
APPLE_TEAM_ID=XXXXXXXXXX
|
||||||
|
APPLE_KEY_ID=XXXXXXXXXX
|
||||||
|
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT...这里是.p8文件内容...\n-----END PRIVATE KEY-----"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Apple 回调必须 HTTPS。本地开发可以用 `ngrok http 3000` 获取临时 HTTPS 域名,然后设置 `OAUTH_CALLBACK_BASE_URL=https://xxx.ngrok.io`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通用配置
|
||||||
|
|
||||||
|
`.env` 中还需要设置回调基础 URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境改为实际域名即可。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
配置好后启动 `pnpm dev:server` + `pnpm dev:web`,访问 `/login` 页面点击对应按钮即可测试。
|
||||||
51
docs/project-management/api-key-management.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# API Key 管理
|
||||||
|
|
||||||
|
API Key 是 LLM 客户端连接 AgentFox MCP 服务的唯一认证凭证。
|
||||||
|
|
||||||
|
## 基本信息
|
||||||
|
|
||||||
|
- 每个账号只有一个 API Key
|
||||||
|
- 该 Key 对账号下所有项目生效
|
||||||
|
- Key 以 `afk_` 为前缀
|
||||||
|
- 使用 bcrypt 哈希加密存储,不保存明文
|
||||||
|
|
||||||
|
## 生成 API Key
|
||||||
|
|
||||||
|
首次使用时需要生成:
|
||||||
|
|
||||||
|
1. 打开「设置」(点击右上角头像旁的齿轮图标)
|
||||||
|
2. 在「API Key」区域,点击「生成 API Key」
|
||||||
|
3. **立即复制并保存**生成的密钥
|
||||||
|
|
||||||
|
> **重要**:API Key 仅在生成时完整显示一次。
|
||||||
|
|
||||||
|
## 查看/复制已有 Key
|
||||||
|
|
||||||
|
如果之前已生成过 API Key:
|
||||||
|
|
||||||
|
1. 打开「设置」→ API Key 区域
|
||||||
|
2. 点击「查看」或「复制」
|
||||||
|
3. 输入账号密码进行身份验证
|
||||||
|
4. 验证通过后可查看或复制
|
||||||
|
|
||||||
|
> **提示**:通过 Google/GitHub 登录的用户,需要先在「设置」中设置密码,才能执行此操作。
|
||||||
|
|
||||||
|
## 轮换 API Key
|
||||||
|
|
||||||
|
如果密钥泄露或出于安全考虑需要更换:
|
||||||
|
|
||||||
|
1. 打开「设置」→ API Key 区域
|
||||||
|
2. 点击「轮换 API Key」
|
||||||
|
3. 确认操作
|
||||||
|
|
||||||
|
轮换后的影响:
|
||||||
|
- 旧密钥**立即失效**
|
||||||
|
- 所有使用旧密钥的 MCP 客户端需要更新配置
|
||||||
|
- 新密钥同样只显示一次,请立即保存
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
- 不要将 API Key 提交到版本控制系统
|
||||||
|
- 将包含 API Key 的 MCP 配置文件添加到 `.gitignore`
|
||||||
|
- 定期轮换 API Key
|
||||||
|
- 如果怀疑密钥泄露,立即轮换
|
||||||
45
docs/project-management/module-management.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 模块管理
|
||||||
|
|
||||||
|
模块是 AgentFox 中端点的分组方式。导入 API 文档时,端点会自动按规则分组到不同模块中。
|
||||||
|
|
||||||
|
## 自动分组规则
|
||||||
|
|
||||||
|
导入 API 文档时,AgentFox 按以下优先级自动创建模块:
|
||||||
|
|
||||||
|
1. **OpenAPI Tags**:如果端点定义了 `tags`,按 tag 名称分组
|
||||||
|
2. **路径前缀**:如果没有 tags,按 URL 路径的第一段分组(如 `/users/...` → `users` 模块)
|
||||||
|
|
||||||
|
## 查看模块
|
||||||
|
|
||||||
|
在项目详情页的「模块」标签中,可以查看所有模块:
|
||||||
|
|
||||||
|
- 模块名称
|
||||||
|
- 模块中的端点数量
|
||||||
|
- 模块来源类型(tag / path_prefix / manual)
|
||||||
|
|
||||||
|
## 手动添加模块
|
||||||
|
|
||||||
|
1. 在「模块」标签页顶部,输入模块名称
|
||||||
|
2. 点击「添加」按钮
|
||||||
|
3. 新模块将显示在列表中(端点数量为 0)
|
||||||
|
|
||||||
|
手动添加的模块可用于重新组织端点。
|
||||||
|
|
||||||
|
## 删除模块
|
||||||
|
|
||||||
|
1. 在模块列表中,找到要删除的模块
|
||||||
|
2. 点击删除按钮
|
||||||
|
3. 确认删除
|
||||||
|
|
||||||
|
> **注意**:删除模块会同时删除模块中的所有端点。此操作不可撤销。
|
||||||
|
|
||||||
|
## 模块对 MCP 工具的影响
|
||||||
|
|
||||||
|
模块直接影响 LLM 的查询体验:
|
||||||
|
|
||||||
|
- `get_project_overview` 返回所有模块的 ID 和端点数量
|
||||||
|
- `list_modules` 返回模块的详细描述
|
||||||
|
- `list_endpoints` 需要传入 `moduleId` 来查看特定模块的端点
|
||||||
|
- `search_endpoints` 可选传入 `moduleId` 来限定搜索范围
|
||||||
|
|
||||||
|
合理的模块划分有助于 LLM 更快定位到所需的端点。
|
||||||
34
docs/project-management/reimport-docs.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 重新导入文档
|
||||||
|
|
||||||
|
当 API 文档更新后,你可以重新导入来更新 AgentFox 中的数据。
|
||||||
|
|
||||||
|
## 何时需要重新导入?
|
||||||
|
|
||||||
|
- API 新增了端点
|
||||||
|
- 端点的参数或响应格式发生了变化
|
||||||
|
- 模块结构需要重新组织
|
||||||
|
|
||||||
|
## 重新导入步骤
|
||||||
|
|
||||||
|
1. 进入项目详情页 → 「设置」标签
|
||||||
|
2. 在「重新导入 API 文档」区域,点击「重新导入文档」
|
||||||
|
3. 选择导入方式(URL 或上传文件)
|
||||||
|
4. 确认导入
|
||||||
|
|
||||||
|
## 重要提醒
|
||||||
|
|
||||||
|
> **注意**:重新导入会删除当前项目中的所有模块和端点,然后根据新文档重新创建。
|
||||||
|
|
||||||
|
重新导入时:
|
||||||
|
- 所有现有模块将被删除
|
||||||
|
- 所有现有端点将被删除
|
||||||
|
- **API Key 保持不变**(LLM 客户端无需更新配置)
|
||||||
|
- **项目 ID 保持不变**(MCP URL 不变)
|
||||||
|
|
||||||
|
## 支持的格式
|
||||||
|
|
||||||
|
与首次导入相同:
|
||||||
|
- OpenAPI 3.0 / 3.1
|
||||||
|
- Swagger 2.0
|
||||||
|
- JSON 和 YAML 格式
|
||||||
|
- URL 或文件上传
|
||||||
3551
docs/superpowers/plans/2026-04-02-agent-fox-implementation.md
Normal file
1468
docs/superpowers/plans/2026-04-03-login-page-oauth.md
Normal file
335
docs/superpowers/specs/2026-04-02-agent-fox-design.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Agent Fox - API Documentation MCP Service
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Developers using LLMs (Claude, GPT, etc.) often need to reference API documentation while coding. Currently, they either paste entire API docs into the context (wasting tokens) or manually copy relevant sections. Agent Fox solves this by providing an MCP service that lets LLMs efficiently query API documentation through multi-level retrieval, minimizing token consumption while maximizing usefulness.
|
||||||
|
|
||||||
|
**Target**: Developer-facing SaaS product with user authentication and multi-tenant isolation.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Monorepo Structure (pnpm workspace)
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-fox/
|
||||||
|
├── packages/
|
||||||
|
│ ├── web/ # React frontend (Vite + TailwindCSS + shadcn/ui)
|
||||||
|
│ ├── server/ # Express backend API
|
||||||
|
│ ├── mcp/ # MCP service (independent Express process)
|
||||||
|
│ └── shared/ # Shared types + Prisma client
|
||||||
|
├── prisma/ # Prisma schema + migrations
|
||||||
|
├── package.json
|
||||||
|
├── pnpm-workspace.yaml
|
||||||
|
└── tsconfig.base.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- `server` and `mcp` are independently deployable processes sharing the same PostgreSQL database via the `shared` Prisma client.
|
||||||
|
- `web` is a static SPA served separately (or via CDN).
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Frontend | React 19 + Vite + TailwindCSS + shadcn/ui + React Router + TanStack Query |
|
||||||
|
| Backend API | Express + TypeScript + Zod (validation) |
|
||||||
|
| MCP Service | `@modelcontextprotocol/server` + `@modelcontextprotocol/express` + `@modelcontextprotocol/node` |
|
||||||
|
| Database | PostgreSQL + Prisma ORM |
|
||||||
|
| OpenAPI Parsing | `@apidevtools/swagger-parser` |
|
||||||
|
| Auth | JWT (access + refresh) + bcrypt + Passport.js (GitHub/Google OAuth) |
|
||||||
|
| Language | TypeScript throughout |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### User
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | UUID | Primary key |
|
||||||
|
| email | String | Unique, for email/password auth |
|
||||||
|
| passwordHash | String? | Nullable for OAuth-only users |
|
||||||
|
| name | String | Display name |
|
||||||
|
| avatarUrl | String? | Profile picture |
|
||||||
|
| createdAt | DateTime | |
|
||||||
|
| updatedAt | DateTime | |
|
||||||
|
|
||||||
|
### OAuthAccount
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | UUID | Primary key |
|
||||||
|
| userId | UUID | FK → User |
|
||||||
|
| provider | String | "github" or "google" |
|
||||||
|
| providerAccountId | String | External account ID |
|
||||||
|
| createdAt | DateTime | |
|
||||||
|
|
||||||
|
### Project
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | UUID | Primary key, exposed as project ID |
|
||||||
|
| userId | UUID | FK → User (owner) |
|
||||||
|
| name | String | Project display name |
|
||||||
|
| description | String? | Optional description |
|
||||||
|
| baseUrl | String? | API base URL |
|
||||||
|
| openApiSpec | JSONB | Full dereferenced OpenAPI document |
|
||||||
|
| openApiVersion | String | e.g., "3.0.3", "3.1.0" |
|
||||||
|
| apiKeyHash | String | Hashed API key for MCP access |
|
||||||
|
| createdAt | DateTime | |
|
||||||
|
| updatedAt | DateTime | |
|
||||||
|
|
||||||
|
### Module
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | UUID | Primary key |
|
||||||
|
| projectId | UUID | FK → Project |
|
||||||
|
| name | String | Module name (from tag or path prefix) |
|
||||||
|
| description | String? | Module description |
|
||||||
|
| sortOrder | Int | Display order |
|
||||||
|
| source | Enum | "tag", "path_prefix", "manual" |
|
||||||
|
| createdAt | DateTime | |
|
||||||
|
| updatedAt | DateTime | |
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | UUID | Primary key |
|
||||||
|
| projectId | UUID | FK → Project |
|
||||||
|
| moduleId | UUID | FK → Module |
|
||||||
|
| method | String | HTTP method (GET, POST, PUT, DELETE, etc.) |
|
||||||
|
| path | String | URL path (e.g., /api/users/{id}) |
|
||||||
|
| summary | String? | Short description |
|
||||||
|
| description | String? | Detailed description |
|
||||||
|
| operationId | String? | OpenAPI operationId |
|
||||||
|
| parameters | JSONB | Path, query, header parameters |
|
||||||
|
| requestBody | JSONB? | Request body schema |
|
||||||
|
| responses | JSONB | Response schemas by status code |
|
||||||
|
| tags | String[] | Original OpenAPI tags |
|
||||||
|
| deprecated | Boolean | Whether the endpoint is deprecated |
|
||||||
|
| createdAt | DateTime | |
|
||||||
|
| updatedAt | DateTime | |
|
||||||
|
|
||||||
|
## MCP Multi-Level Retrieval Design (Core Feature)
|
||||||
|
|
||||||
|
The MCP service exposes 5 tools that enable LLMs to progressively drill down into API documentation, minimizing token usage at each step.
|
||||||
|
|
||||||
|
### Tool Definitions
|
||||||
|
|
||||||
|
#### 1. `get_project_overview`
|
||||||
|
- **Description** (shown to LLM): "Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers. This is usually sufficient to decide which module to drill into."
|
||||||
|
- **Input**: (none — projectId comes from the MCP connection URL)
|
||||||
|
- **Output**: `{ name, description, version, baseUrl, totalEndpoints, modules: [{ id, name, endpointCount }] }`
|
||||||
|
- **Estimated tokens**: ~200
|
||||||
|
- **Note**: This is the recommended entry point. It provides a compact overview including module names and counts, enough for LLMs to decide next steps.
|
||||||
|
|
||||||
|
#### 2. `list_modules`
|
||||||
|
- **Description**: "List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore."
|
||||||
|
- **Input**: (none)
|
||||||
|
- **Output**: `[{ id, name, description, endpointCount }]`
|
||||||
|
- **Estimated tokens**: ~100-300
|
||||||
|
- **Note**: Differs from `get_project_overview` by including module descriptions. Use when the module name alone isn't enough to determine relevance.
|
||||||
|
|
||||||
|
#### 3. `list_endpoints`
|
||||||
|
- **Description**: "List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint."
|
||||||
|
- **Input**: `{ moduleId: string }`
|
||||||
|
- **Output**: `[{ id, method, path, summary, deprecated }]`
|
||||||
|
- **Estimated tokens**: ~200-500
|
||||||
|
|
||||||
|
#### 4. `get_endpoint_detail`
|
||||||
|
- **Description**: "Get complete details for a specific endpoint including parameters, request body schema, response schemas, and examples. Use this when you need to understand exactly how to call an endpoint."
|
||||||
|
- **Input**: `{ endpointId: string }`
|
||||||
|
- **Output**: `{ method, path, summary, description, parameters, requestBody, responses, deprecated }`
|
||||||
|
- **Estimated tokens**: ~500-2000
|
||||||
|
|
||||||
|
#### 5. `search_endpoints`
|
||||||
|
- **Description**: "Search for endpoints by keyword. Searches across path, summary, description, operationId, and parameter names. Optionally filter by module. Returns matching endpoint summaries."
|
||||||
|
- **Input**: `{ keyword: string, moduleId?: string }`
|
||||||
|
- **Output**: `[{ id, method, path, summary, moduleName, deprecated }]`
|
||||||
|
- **Estimated tokens**: ~200-500
|
||||||
|
|
||||||
|
### Retrieval Flow Example
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM wants to call "create user" endpoint:
|
||||||
|
1. get_project_overview() → ~200 tokens (see all modules)
|
||||||
|
2. list_endpoints({ moduleId: "users" }) → ~300 tokens (see user endpoints)
|
||||||
|
3. get_endpoint_detail({ endpointId: "..." }) → ~800 tokens (get full details)
|
||||||
|
Total: ~1,300 tokens (vs 10,000+ for full doc dump)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Authentication
|
||||||
|
|
||||||
|
- MCP endpoint URL: `https://host/mcp/:projectId`
|
||||||
|
- Auth via `Authorization: Bearer <project-api-key>` header
|
||||||
|
- API key is validated against the project's `apiKeyHash`
|
||||||
|
- Each project has its own isolated MCP instance
|
||||||
|
|
||||||
|
### MCP Transport
|
||||||
|
|
||||||
|
Support both transport protocols:
|
||||||
|
- **Streamable HTTP** (new standard): POST/GET/DELETE on `/mcp/:projectId`
|
||||||
|
- **SSE** (legacy): GET with SSE on `/mcp/:projectId/sse`, POST on `/mcp/:projectId/messages`
|
||||||
|
|
||||||
|
Use `@modelcontextprotocol/express` middleware with session management for stateful connections.
|
||||||
|
|
||||||
|
## Frontend Pages
|
||||||
|
|
||||||
|
### 1. Auth Pages
|
||||||
|
- Login: email/password form + GitHub/Google OAuth buttons
|
||||||
|
- Register: email/password form + OAuth
|
||||||
|
|
||||||
|
### 2. Projects List Page
|
||||||
|
- Card grid of user's projects
|
||||||
|
- Each card: project name, version, endpoint count, created date
|
||||||
|
- Create project button → import flow
|
||||||
|
|
||||||
|
### 3. Project Detail Page (Tabbed)
|
||||||
|
|
||||||
|
**Tab: Documentation Preview**
|
||||||
|
- Interactive API doc browser (similar to Swagger UI)
|
||||||
|
- Left sidebar: module list (collapsible)
|
||||||
|
- Main area: endpoint list grouped by module, expandable to show details
|
||||||
|
- Supports try-it-out (optional, future feature)
|
||||||
|
|
||||||
|
**Tab: Module Management**
|
||||||
|
- View auto-generated modules
|
||||||
|
- Drag-and-drop reorder
|
||||||
|
- Move endpoints between modules
|
||||||
|
- Create/rename/delete modules
|
||||||
|
|
||||||
|
**Tab: MCP Integration**
|
||||||
|
- MCP service URL (copyable)
|
||||||
|
- API Key display (masked, with copy and rotate buttons)
|
||||||
|
- Configuration snippet for Claude Code, Cursor, etc. (copyable JSON)
|
||||||
|
- Connection status indicator
|
||||||
|
|
||||||
|
**Tab: Settings**
|
||||||
|
- Project name/description editing
|
||||||
|
- Re-import OpenAPI document (with diff preview)
|
||||||
|
- Danger zone: delete project
|
||||||
|
|
||||||
|
### Import Flow
|
||||||
|
1. User uploads JSON/YAML file OR pastes URL
|
||||||
|
2. Backend validates with `@apidevtools/swagger-parser`
|
||||||
|
3. Backend dereferences all `$ref` pointers
|
||||||
|
4. Parses tags → Module records, paths → Endpoint records
|
||||||
|
5. Endpoints without tags auto-grouped by path prefix (first segment)
|
||||||
|
6. Preview shown to user with module/endpoint breakdown
|
||||||
|
7. User confirms → data saved
|
||||||
|
|
||||||
|
## Backend API
|
||||||
|
|
||||||
|
### Auth Routes
|
||||||
|
```
|
||||||
|
POST /api/auth/register # Email registration
|
||||||
|
POST /api/auth/login # Email login → returns JWT pair
|
||||||
|
POST /api/auth/refresh # Refresh access token
|
||||||
|
GET /api/auth/github # GitHub OAuth redirect
|
||||||
|
GET /api/auth/google # Google OAuth redirect
|
||||||
|
GET /api/auth/callback/:provider # OAuth callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Routes
|
||||||
|
```
|
||||||
|
GET /api/projects # List user's projects
|
||||||
|
POST /api/projects # Create project (upload OpenAPI doc)
|
||||||
|
GET /api/projects/:id # Get project details
|
||||||
|
PUT /api/projects/:id # Update project metadata
|
||||||
|
DELETE /api/projects/:id # Delete project
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module/Endpoint Routes
|
||||||
|
```
|
||||||
|
GET /api/projects/:id/modules # List modules
|
||||||
|
PUT /api/projects/:id/modules/:mid # Update module (rename, reorder)
|
||||||
|
POST /api/projects/:id/modules # Create manual module
|
||||||
|
DELETE /api/projects/:id/modules/:mid # Delete module
|
||||||
|
PATCH /api/projects/:id/endpoints/:eid # Move endpoint to different module
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import/Key Routes
|
||||||
|
```
|
||||||
|
POST /api/projects/:id/reimport # Re-import OpenAPI document
|
||||||
|
POST /api/projects/:id/api-key/rotate # Rotate API key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Design
|
||||||
|
- **User auth**: JWT dual-token (access: 15min, refresh: 7d)
|
||||||
|
- **Password**: bcrypt hashed
|
||||||
|
- **OAuth**: Passport.js strategies for GitHub and Google
|
||||||
|
- **MCP auth**: Project-level API key (independent from user JWT)
|
||||||
|
- API key format: `afk_` prefix + 32-char random string
|
||||||
|
- API key stored as bcrypt hash in database
|
||||||
|
|
||||||
|
## Deployment (Docker Compose)
|
||||||
|
|
||||||
|
All services containerized and orchestrated via Docker Compose for one-command deployment.
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres: # PostgreSQL 16
|
||||||
|
server: # Backend API (Express) - port 3000
|
||||||
|
mcp: # MCP service (Express) - port 3001
|
||||||
|
web: # Frontend (Nginx serving static build) - port 80
|
||||||
|
redis: # Optional: session store / rate limiting cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Details
|
||||||
|
|
||||||
|
| Service | Base Image | Notes |
|
||||||
|
|---------|-----------|-------|
|
||||||
|
| `postgres` | `postgres:16-alpine` | Persistent volume for data, init scripts for DB creation |
|
||||||
|
| `server` | `node:20-alpine` | Multi-stage build: build → runtime only |
|
||||||
|
| `mcp` | `node:20-alpine` | Same multi-stage build pattern |
|
||||||
|
| `web` | `node:20-alpine` → `nginx:alpine` | Build stage + Nginx serve stage |
|
||||||
|
| `redis` | `redis:7-alpine` | Optional, for rate limiting and session cache |
|
||||||
|
|
||||||
|
### Docker Files
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-fox/
|
||||||
|
├── docker-compose.yml # Orchestration
|
||||||
|
├── docker-compose.dev.yml # Dev overrides (hot reload, debug ports)
|
||||||
|
├── packages/
|
||||||
|
│ ├── web/Dockerfile
|
||||||
|
│ ├── server/Dockerfile
|
||||||
|
│ └── mcp/Dockerfile
|
||||||
|
└── .env.example # Environment variable template
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Managed via `.env` file (git-ignored), with `.env.example` as template:
|
||||||
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
|
- `JWT_SECRET` — JWT signing key
|
||||||
|
- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` — GitHub OAuth
|
||||||
|
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` — Google OAuth
|
||||||
|
- `MCP_BASE_URL` — Public URL for MCP service
|
||||||
|
- `REDIS_URL` — Optional Redis connection
|
||||||
|
|
||||||
|
### Dev vs Prod
|
||||||
|
|
||||||
|
- **Dev** (`docker-compose -f docker-compose.yml -f docker-compose.dev.yml up`): Source mounted as volumes, hot reload enabled, debug ports exposed
|
||||||
|
- **Prod** (`docker-compose up`): Optimized multi-stage builds, no source mounting, Nginx serves frontend
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- All API responses follow `{ success: boolean, data?: T, error?: { code, message } }` format
|
||||||
|
- Zod validation on all inputs
|
||||||
|
- MCP tools return structured error messages that help LLMs self-correct
|
||||||
|
- Rate limiting on MCP endpoints to prevent abuse
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- OpenAPI parsing: validate correct module/endpoint extraction from sample docs
|
||||||
|
- MCP tools: verify each tool returns correct data shape and respects scoping
|
||||||
|
- Auth: test JWT generation, validation, refresh flow
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Full import flow: upload OpenAPI doc → verify modules/endpoints created correctly
|
||||||
|
- MCP retrieval flow: simulate LLM calling tools in sequence
|
||||||
|
- Auth flow: register → login → access protected routes
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
- Import Petstore OpenAPI sample → verify preview and module grouping
|
||||||
|
- Configure Claude Code with generated MCP config → verify tools work
|
||||||
|
- Test search with various keywords → verify relevance
|
||||||
144
docs/superpowers/specs/2026-04-03-login-page-oauth-design.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Login Page Redesign + OAuth Support
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Redesign the login/register pages with a left-right split layout featuring prominent branding, and add Google/GitHub/Apple OAuth login via standard server-side redirect flow.
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- **Desktop**: 50/50 left-right split
|
||||||
|
- **Mobile**: Brand area hidden or collapsed to compact top banner; form area full-width
|
||||||
|
|
||||||
|
### Left Panel (Brand Area)
|
||||||
|
|
||||||
|
Shared `AuthBranding` component used by both Login and Register pages.
|
||||||
|
|
||||||
|
- Dark/gradient background (fox-amber → fox-orange gradient from existing CSS variables)
|
||||||
|
- Large product icon (~80px SVG fox logo)
|
||||||
|
- Product name "AgentFox" (large heading font)
|
||||||
|
- Slogan "API Docs for LLMs, Done Right"
|
||||||
|
- 3 feature highlights, each with icon + text:
|
||||||
|
- "Multi-level API retrieval for minimal token usage"
|
||||||
|
- "Import OpenAPI specs in seconds"
|
||||||
|
- "Works with any MCP-compatible LLM"
|
||||||
|
|
||||||
|
### Right Panel (Form Area)
|
||||||
|
|
||||||
|
- Light background, vertically centered
|
||||||
|
- Title: "Sign in to your account" (login) / "Create your account" (register)
|
||||||
|
- Email + Password inputs (reuse existing input styles)
|
||||||
|
- Primary action button
|
||||||
|
- Divider: "── or continue with ──"
|
||||||
|
- Three OAuth buttons in a row: Google / GitHub / Apple (each with official SVG icon)
|
||||||
|
- Footer link: "Don't have an account? Sign up" / "Already have an account? Sign in"
|
||||||
|
|
||||||
|
## OAuth Architecture
|
||||||
|
|
||||||
|
### Flow (Standard Server-Side Redirect)
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser clicks OAuth button
|
||||||
|
→ GET /api/auth/oauth/:provider
|
||||||
|
→ Server builds authorization URL with state param, 302 redirects to Provider
|
||||||
|
→ User authorizes on Provider's page
|
||||||
|
→ Provider redirects to GET /api/auth/oauth/:provider/callback?code=xxx&state=yyy
|
||||||
|
→ Server validates state, exchanges code for access_token
|
||||||
|
→ Server fetches user info (email, name, avatar)
|
||||||
|
→ Server finds or creates user (see Account Linking below)
|
||||||
|
→ Server issues JWT (accessToken + refreshToken)
|
||||||
|
→ Server 302 redirects to frontend /login/callback?accessToken=xxx&refreshToken=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Account Linking Strategy
|
||||||
|
|
||||||
|
On OAuth callback, the server resolves the user in this order:
|
||||||
|
|
||||||
|
1. Look up `OAuthAccount` by `(provider, providerAccountId)` → if found, use linked `User`
|
||||||
|
2. If no OAuthAccount match, look up `User` by `email` → if found, create `OAuthAccount` linking to existing user
|
||||||
|
3. If no User match, create new `User` (passwordHash=null, name and avatarUrl from provider) + new `OAuthAccount`
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **CSRF protection**: Generate random `state` parameter per auth request, store in in-memory Map with 10-minute TTL, validate on callback
|
||||||
|
- **Token delivery**: Tokens passed via URL query params; frontend immediately consumes and clears URL
|
||||||
|
- **Secrets**: All client secrets stay server-side; no OAuth SDK loaded in frontend
|
||||||
|
|
||||||
|
### Provider Configuration
|
||||||
|
|
||||||
|
| Provider | Auth URL | Token URL | UserInfo URL | Scopes |
|
||||||
|
|----------|----------|-----------|--------------|--------|
|
||||||
|
| Google | accounts.google.com/o/oauth2/v2/auth | oauth2.googleapis.com/token | www.googleapis.com/oauth2/v2/userinfo | email, profile |
|
||||||
|
| GitHub | github.com/login/oauth/authorize | github.com/login/oauth/access_token | api.github.com/user + /user/emails | user:email |
|
||||||
|
| Apple | appleid.apple.com/auth/authorize | appleid.apple.com/auth/token | (decoded from id_token) | name, email |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Already in .env.example
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# New
|
||||||
|
APPLE_CLIENT_ID=
|
||||||
|
APPLE_TEAM_ID=
|
||||||
|
APPLE_KEY_ID=
|
||||||
|
APPLE_PRIVATE_KEY=
|
||||||
|
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Callback Page (`/login/callback`)
|
||||||
|
|
||||||
|
- Extracts `accessToken` and `refreshToken` from URL search params
|
||||||
|
- Stores in localStorage, updates AuthContext
|
||||||
|
- Redirects to `/dashboard` (or saved redirect target)
|
||||||
|
- Shows loading spinner during processing
|
||||||
|
- Shows error message with retry link on failure
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `packages/server/src/routes/oauth.ts` | OAuth routes (/:provider, /:provider/callback) |
|
||||||
|
| `packages/server/src/lib/oauth-providers.ts` | Provider configs + token exchange + userinfo fetch |
|
||||||
|
| `packages/web/src/pages/LoginCallback.tsx` | OAuth callback landing page |
|
||||||
|
| `packages/web/src/components/AuthBranding.tsx` | Shared left-panel brand component |
|
||||||
|
| `packages/web/src/components/OAuthButtons.tsx` | Third-party login button group |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `packages/server/src/index.ts` | Register `/auth/oauth` route |
|
||||||
|
| `packages/web/src/pages/Login.tsx` | Refactor to left-right split layout |
|
||||||
|
| `packages/web/src/pages/Register.tsx` | Refactor to left-right split layout |
|
||||||
|
| `packages/web/src/App.tsx` | Add `/login/callback` route |
|
||||||
|
| `packages/web/src/lib/i18n.tsx` | Add translation keys |
|
||||||
|
| `.env.example` | Add Apple OAuth env vars |
|
||||||
|
|
||||||
|
### No Changes Needed
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` — OAuthAccount model already exists
|
||||||
|
- JWT signing logic — reuse existing `generateAccessToken`/`generateRefreshToken`
|
||||||
|
- Existing email/password auth — unchanged
|
||||||
|
|
||||||
|
### No New Dependencies
|
||||||
|
|
||||||
|
- OAuth token exchange: Node native `fetch`
|
||||||
|
- Apple JWT client_secret signing: Node `crypto` built-in
|
||||||
|
- No Passport.js, no OAuth libraries
|
||||||
|
|
||||||
|
## User Action Required
|
||||||
|
|
||||||
|
Before testing OAuth, the developer must register apps on each provider:
|
||||||
|
|
||||||
|
- **Google**: Google Cloud Console → OAuth 2.0 Client → redirect URI: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/google/callback`
|
||||||
|
- **GitHub**: GitHub Developer Settings → OAuth App → callback URL: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/github/callback`
|
||||||
|
- **Apple**: Apple Developer → Services ID + Key → return URL: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/apple/callback` (requires HTTPS)
|
||||||
|
|
||||||
|
Apple Sign In requires a paid Apple Developer account ($99/year) and HTTPS for callbacks. If unavailable, the Apple button can be displayed as "Coming Soon".
|
||||||
108
docs/superpowers/specs/2026-04-04-admin-dashboard-design.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Admin Dashboard Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add an admin web dashboard to the existing Agent Fox SPA, accessible at `/admin/*` routes. Provides real-time platform statistics, user management, project management, and MCP call log viewing.
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
### User model additions
|
||||||
|
- `role`: enum `Role` (`USER` | `ADMIN`), default `USER`
|
||||||
|
- `disabled`: Boolean, default `false`
|
||||||
|
|
||||||
|
### New model: McpCallLog
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String (UUID) | Primary key |
|
||||||
|
| projectId | String (FK) | Reference to Project |
|
||||||
|
| toolName | String | MCP tool name called |
|
||||||
|
| calledAt | DateTime | Timestamp |
|
||||||
|
| durationMs | Int | Response time in ms |
|
||||||
|
| success | Boolean | Whether call succeeded |
|
||||||
|
| requestParams | Json | Request parameters |
|
||||||
|
| responseSize | Int | Response size in bytes |
|
||||||
|
| clientIp | String | Caller IP address |
|
||||||
|
| estimatedTokens | Int? | Estimated token consumption |
|
||||||
|
|
||||||
|
Indexes: `projectId`, `calledAt`, `toolName`
|
||||||
|
|
||||||
|
## Backend API
|
||||||
|
|
||||||
|
### New middleware
|
||||||
|
- `requireAdmin`: verifies `role === 'ADMIN'` from JWT payload. Returns 403 if not admin.
|
||||||
|
- Login check: `disabled === true` users get 403 on login.
|
||||||
|
|
||||||
|
### New routes (`/api/admin/`)
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | /stats | Aggregate stats (user count, project count, call count, today's active) |
|
||||||
|
| GET | /stats/trends | Time-series data (7d/30d call trends) |
|
||||||
|
| GET | /users | Paginated user list with search/sort |
|
||||||
|
| GET | /users/:id | User detail + their projects |
|
||||||
|
| PATCH | /users/:id/disable | Toggle user disabled status |
|
||||||
|
| GET | /projects | Global paginated project list |
|
||||||
|
| GET | /projects/:id | Project detail |
|
||||||
|
| DELETE | /projects/:id | Delete project |
|
||||||
|
| GET | /call-logs | Paginated call logs with filters |
|
||||||
|
|
||||||
|
### MCP call logging
|
||||||
|
In `packages/mcp`, wrap each tool handler to record a `McpCallLog` entry with timing, params, success/failure, response size, client IP, and token estimate.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
```
|
||||||
|
/admin → Dashboard (stats overview)
|
||||||
|
/admin/users → User management list
|
||||||
|
/admin/users/:id → User detail
|
||||||
|
/admin/projects → Project management list
|
||||||
|
/admin/projects/:id → Project detail
|
||||||
|
/admin/logs → Call log viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
### AdminLayout
|
||||||
|
- Left sidebar (200px): nav links for Dashboard / Users / Projects / Logs
|
||||||
|
- Top header: reuse theme toggle + user menu from existing Layout
|
||||||
|
- Route guard: redirect non-admin users to `/dashboard`
|
||||||
|
- Separate from existing `Layout.tsx`, parallel structure
|
||||||
|
|
||||||
|
### Dashboard page
|
||||||
|
| Card | Content |
|
||||||
|
|------|---------|
|
||||||
|
| Registered Users | Total + today's new |
|
||||||
|
| Projects | Total + today's new |
|
||||||
|
| MCP Calls | Total + today's calls |
|
||||||
|
| Active Users (7d) | Users with activity in past 7 days |
|
||||||
|
| Avg Response Time | Mean durationMs of MCP calls |
|
||||||
|
| Success Rate | Percentage of successful calls |
|
||||||
|
|
||||||
|
Below cards: 7-day call trend chart + recent calls table.
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
- Table: name, email, role, projects count, created date, status (active/disabled)
|
||||||
|
- Search by name/email
|
||||||
|
- Actions: view detail, toggle disable
|
||||||
|
- Detail page: user info + list of their projects
|
||||||
|
|
||||||
|
### Project Management
|
||||||
|
- Table: name, owner, endpoints count, modules count, created date
|
||||||
|
- Search by name
|
||||||
|
- Actions: view detail, delete (with confirmation)
|
||||||
|
- Detail page: project info, modules, endpoints summary
|
||||||
|
|
||||||
|
### Call Logs
|
||||||
|
- Table: time, project name, tool name, duration, success, client IP
|
||||||
|
- Filters: project, tool name, date range, success/failure
|
||||||
|
- Pagination
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
- JWT payload adds `role` field
|
||||||
|
- Frontend stores role in auth context
|
||||||
|
- Admin nav entry only visible to admin users
|
||||||
|
- Non-admin accessing `/admin/*` → redirect to `/dashboard`
|
||||||
|
|
||||||
|
## Tech Stack (frontend)
|
||||||
|
- Same React 19 + React Router 7 + Tailwind CSS v4
|
||||||
|
- Reuse existing custom components (Badge, Modal, ConfirmDialog, etc.)
|
||||||
|
- Charts: lightweight solution (CSS-based or small chart lib)
|
||||||
|
- No new component library
|
||||||
36
packages/mcp/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
RUN npm install -g typescript@5
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY prisma/ prisma/
|
||||||
|
|
||||||
|
# shared: 安装依赖 + prisma generate
|
||||||
|
COPY packages/shared/package.json packages/shared/tsconfig.json packages/shared/
|
||||||
|
RUN cd packages/shared && npm install && npx prisma generate --schema=../../prisma/schema.prisma
|
||||||
|
|
||||||
|
# mcp: 安装依赖(workspace:* → file: 引用)
|
||||||
|
COPY packages/mcp/package.json packages/mcp/tsconfig.json packages/mcp/
|
||||||
|
RUN cd packages/mcp && sed -i 's|"workspace:\*"|"file:../shared"|g' package.json && npm install
|
||||||
|
|
||||||
|
# 拷贝源码 + 编译
|
||||||
|
COPY packages/shared/src/ packages/shared/src/
|
||||||
|
COPY packages/mcp/src/ packages/mcp/src/
|
||||||
|
|
||||||
|
RUN tsc -p packages/shared/tsconfig.json
|
||||||
|
RUN tsc -p packages/mcp/tsconfig.json
|
||||||
|
|
||||||
|
# --- 精简运行时镜像 ---
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||||
|
COPY --from=build /app/packages/shared/package.json ./packages/shared/
|
||||||
|
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||||
|
COPY --from=build /app/packages/mcp/dist ./packages/mcp/dist
|
||||||
|
COPY --from=build /app/packages/mcp/package.json ./packages/mcp/
|
||||||
|
COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
|
||||||
|
|
||||||
|
WORKDIR /app/packages/mcp
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -12,22 +12,37 @@ export async function mcpAuth(req: Request, res: Response, next: NextFunction):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = header.slice(7);
|
const apiKey = header.slice(7);
|
||||||
const project = await prisma.project.findUnique({
|
const prefix = apiKey.slice(0, 12);
|
||||||
where: { id: projectId },
|
|
||||||
|
// Find user by API key prefix for fast lookup
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { apiKeyPrefix: prefix },
|
||||||
select: { id: true, apiKeyHash: true },
|
select: { id: true, apiKeyHash: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!user || !user.apiKeyHash) {
|
||||||
|
res.status(401).json({ error: 'Invalid API key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify API key with bcrypt
|
||||||
|
const valid = await bcrypt.compare(apiKey, user.apiKeyHash);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ error: 'Invalid API key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns the project
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: projectId, userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
res.status(404).json({ error: 'Project not found' });
|
res.status(404).json({ error: 'Project not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(apiKey, project.apiKeyHash);
|
|
||||||
if (!valid) {
|
|
||||||
res.status(401).json({ error: 'Invalid API key' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(req as any).projectId = projectId;
|
(req as any).projectId = projectId;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ app.post('/mcp/:projectId', mcpAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = createMcpServer(projectId);
|
const forwarded = req.headers['x-forwarded-for'] as string | undefined;
|
||||||
|
const clientIp = forwarded?.split(',')[0].trim() || req.socket.remoteAddress || '';
|
||||||
|
const server = createMcpServer(projectId, clientIp);
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
});
|
});
|
||||||
|
|||||||
45
packages/mcp/src/lib/call-logger.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
|
||||||
|
type CallContext = {
|
||||||
|
projectId: string;
|
||||||
|
toolName: string;
|
||||||
|
requestParams: Record<string, unknown>;
|
||||||
|
clientIp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function logMcpCall(ctx: CallContext, fn: () => Promise<any>): Promise<any> {
|
||||||
|
const start = Date.now();
|
||||||
|
let success = true;
|
||||||
|
let result: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await fn();
|
||||||
|
if (result?.isError) success = false;
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
success = false;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const responseText = result ? JSON.stringify(result) : '';
|
||||||
|
const responseSize = Buffer.byteLength(responseText, 'utf-8');
|
||||||
|
// Rough token estimate: ~4 chars per token
|
||||||
|
const estimatedTokens = Math.ceil(responseText.length / 4);
|
||||||
|
|
||||||
|
// Fire-and-forget: don't block the response
|
||||||
|
prisma.mcpCallLog.create({
|
||||||
|
data: {
|
||||||
|
projectId: ctx.projectId,
|
||||||
|
toolName: ctx.toolName,
|
||||||
|
durationMs,
|
||||||
|
success,
|
||||||
|
requestParams: ctx.requestParams as any,
|
||||||
|
responseSize,
|
||||||
|
clientIp: ctx.clientIp,
|
||||||
|
estimatedTokens,
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to log MCP call:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js';
|
|||||||
import { listEndpoints } from './tools/list-endpoints.js';
|
import { listEndpoints } from './tools/list-endpoints.js';
|
||||||
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
|
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
|
||||||
import { searchEndpoints } from './tools/search-endpoints.js';
|
import { searchEndpoints } from './tools/search-endpoints.js';
|
||||||
|
import { logMcpCall } from './lib/call-logger.js';
|
||||||
|
|
||||||
export function createMcpServer(projectId: string): McpServer {
|
export function createMcpServer(projectId: string, clientIp: string = ''): McpServer {
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: 'agent-fox',
|
name: 'agent-fox',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ctx = (toolName: string, requestParams: Record<string, unknown> = {}) =>
|
||||||
|
({ projectId, toolName, requestParams, clientIp });
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'get_project_overview',
|
'get_project_overview',
|
||||||
'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.',
|
'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.',
|
||||||
{},
|
{},
|
||||||
async () => getProjectOverview(projectId),
|
async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_modules',
|
'list_modules',
|
||||||
'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.',
|
'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.',
|
||||||
{},
|
{},
|
||||||
async () => listModules(projectId),
|
async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_endpoints',
|
'list_endpoints',
|
||||||
'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.',
|
'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.',
|
||||||
{ moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') },
|
{ moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') },
|
||||||
async ({ moduleId }) => listEndpoints(projectId, moduleId),
|
async ({ moduleId }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'get_endpoint_detail',
|
'get_endpoint_detail',
|
||||||
'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.',
|
'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.',
|
||||||
{ endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') },
|
{ endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') },
|
||||||
async ({ endpointId }) => getEndpointDetail(projectId, endpointId),
|
async ({ endpointId }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
@@ -47,7 +51,7 @@ export function createMcpServer(projectId: string): McpServer {
|
|||||||
keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'),
|
keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'),
|
||||||
moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'),
|
moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'),
|
||||||
},
|
},
|
||||||
async ({ keyword, moduleId }) => searchEndpoints(projectId, keyword, moduleId),
|
async ({ keyword, moduleId }) => logMcpCall(ctx('search_endpoints', { keyword, moduleId }), () => searchEndpoints(projectId, keyword, moduleId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@agent-fox/shared": ["../shared/dist"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
39
packages/server/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
RUN npm install -g typescript@5
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY prisma/ prisma/
|
||||||
|
|
||||||
|
# shared: 安装依赖 + prisma generate
|
||||||
|
COPY packages/shared/package.json packages/shared/tsconfig.json packages/shared/
|
||||||
|
RUN cd packages/shared && npm install && npx prisma generate --schema=../../prisma/schema.prisma
|
||||||
|
|
||||||
|
# server: 安装依赖(workspace:* → file: 引用)
|
||||||
|
COPY packages/server/package.json packages/server/tsconfig.json packages/server/
|
||||||
|
RUN cd packages/server && sed -i 's|"workspace:\*"|"file:../shared"|g' package.json && npm install
|
||||||
|
|
||||||
|
# 拷贝源码 + 编译
|
||||||
|
COPY packages/shared/src/ packages/shared/src/
|
||||||
|
COPY packages/server/src/ packages/server/src/
|
||||||
|
|
||||||
|
RUN tsc -p packages/shared/tsconfig.json
|
||||||
|
RUN tsc -p packages/server/tsconfig.json
|
||||||
|
|
||||||
|
# --- 精简运行时镜像 ---
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
RUN npm install -g prisma@6
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||||
|
COPY --from=build /app/packages/shared/package.json ./packages/shared/
|
||||||
|
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||||
|
COPY --from=build /app/packages/server/dist ./packages/server/dist
|
||||||
|
COPY --from=build /app/packages/server/package.json ./packages/server/
|
||||||
|
COPY --from=build /app/packages/server/node_modules ./packages/server/node_modules
|
||||||
|
COPY --from=build /app/prisma ./prisma
|
||||||
|
COPY scripts/migrate-and-start.sh ./scripts/
|
||||||
|
RUN chmod +x scripts/migrate-and-start.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "scripts/migrate-and-start.sh"]
|
||||||
@@ -1,24 +1,31 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
import oauthRouter from './routes/oauth.js';
|
||||||
import projectRouter from './routes/projects.js';
|
import projectRouter from './routes/projects.js';
|
||||||
import importRouter from './routes/import.js';
|
import importRouter from './routes/import.js';
|
||||||
import moduleRouter from './routes/modules.js';
|
import moduleRouter from './routes/modules.js';
|
||||||
import endpointRouter from './routes/endpoints.js';
|
import endpointRouter from './routes/endpoints.js';
|
||||||
|
import fetchSpecRouter from './routes/fetch-spec.js';
|
||||||
|
import adminRouter from './routes/admin.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ success: true, data: { status: 'ok' } });
|
res.json({ success: true, data: { status: 'ok' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/auth/oauth', oauthRouter);
|
||||||
|
app.use('/api/fetch-spec', fetchSpecRouter);
|
||||||
app.use('/api/projects', projectRouter);
|
app.use('/api/projects', projectRouter);
|
||||||
app.use('/api/projects', importRouter);
|
app.use('/api/projects', importRouter);
|
||||||
app.use('/api/projects', moduleRouter);
|
app.use('/api/projects', moduleRouter);
|
||||||
app.use('/api/projects', endpointRouter);
|
app.use('/api/projects', endpointRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
const port = process.env.SERVER_PORT || 3000;
|
const port = process.env.SERVER_PORT || 3000;
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
|
|||||||
29
packages/server/src/lib/crypto.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
|
||||||
|
function getEncryptionKey(): Buffer {
|
||||||
|
const secret = process.env.API_KEY_ENCRYPTION_SECRET;
|
||||||
|
if (!secret) throw new Error('API_KEY_ENCRYPTION_SECRET environment variable is required');
|
||||||
|
return Buffer.from(secret, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptApiKey(plaintext: string): string {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptApiKey(ciphertext: string): string {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
const [ivB64, tagB64, dataB64] = ciphertext.split(':');
|
||||||
|
const iv = Buffer.from(ivB64, 'base64');
|
||||||
|
const authTag = Buffer.from(tagB64, 'base64');
|
||||||
|
const encrypted = Buffer.from(dataB64, 'base64');
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return decipher.update(encrypted) + decipher.final('utf8');
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { Role } from '@agent-fox/shared';
|
||||||
|
|
||||||
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||||
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
||||||
@@ -8,6 +9,7 @@ const REFRESH_EXPIRY = '7d';
|
|||||||
export type TokenPayload = {
|
export type TokenPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: Role;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateAccessToken(payload: TokenPayload): string {
|
export function generateAccessToken(payload: TokenPayload): string {
|
||||||
|
|||||||
231
packages/server/src/lib/oauth-providers.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
type ProviderConfig = {
|
||||||
|
authUrl: string;
|
||||||
|
tokenUrl: string;
|
||||||
|
userInfoUrl: string | null;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Provider = 'google' | 'github' | 'apple';
|
||||||
|
|
||||||
|
const providers: Record<Provider, ProviderConfig> = {
|
||||||
|
google: {
|
||||||
|
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||||
|
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||||
|
scopes: ['email', 'profile'],
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
authUrl: 'https://github.com/login/oauth/authorize',
|
||||||
|
tokenUrl: 'https://github.com/login/oauth/access_token',
|
||||||
|
userInfoUrl: 'https://api.github.com/user',
|
||||||
|
scopes: ['user:email'],
|
||||||
|
},
|
||||||
|
apple: {
|
||||||
|
authUrl: 'https://appleid.apple.com/auth/authorize',
|
||||||
|
tokenUrl: 'https://appleid.apple.com/auth/token',
|
||||||
|
userInfoUrl: null,
|
||||||
|
scopes: ['name', 'email'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClientId(provider: Provider): string {
|
||||||
|
const envKey = provider === 'apple' ? 'APPLE_CLIENT_ID' : `${provider.toUpperCase()}_CLIENT_ID`;
|
||||||
|
const value = process.env[envKey];
|
||||||
|
if (!value) throw new Error(`Missing env: ${envKey}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientSecret(provider: Provider): string {
|
||||||
|
if (provider === 'apple') return buildAppleClientSecret();
|
||||||
|
const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`;
|
||||||
|
const value = process.env[envKey];
|
||||||
|
if (!value) throw new Error(`Missing env: ${envKey}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCallbackUrl(provider: Provider): string {
|
||||||
|
const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000';
|
||||||
|
return `${base}/api/auth/oauth/${provider}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateStore = new Map<string, { provider: string; createdAt: number; redirect?: string }>();
|
||||||
|
|
||||||
|
const cleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of stateStore) {
|
||||||
|
if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key);
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
cleanupTimer.unref();
|
||||||
|
|
||||||
|
function isValidRedirect(redirect: string): boolean {
|
||||||
|
return redirect.startsWith('/') && !redirect.startsWith('//');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateState(provider: Provider, redirect?: string): string {
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
const safeRedirect = redirect && isValidRedirect(redirect) ? redirect : undefined;
|
||||||
|
stateStore.set(state, { provider, createdAt: Date.now(), redirect: safeRedirect });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateState(state: string, provider: Provider): { valid: boolean; redirect?: string } {
|
||||||
|
const entry = stateStore.get(state);
|
||||||
|
if (!entry) return { valid: false };
|
||||||
|
if (entry.provider !== provider) return { valid: false };
|
||||||
|
if (Date.now() - entry.createdAt > 10 * 60 * 1000) {
|
||||||
|
stateStore.delete(state);
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
const redirect = entry.redirect;
|
||||||
|
stateStore.delete(state);
|
||||||
|
return { valid: true, redirect };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAppleClientSecret(): string {
|
||||||
|
const teamId = process.env.APPLE_TEAM_ID;
|
||||||
|
const keyId = process.env.APPLE_KEY_ID;
|
||||||
|
const privateKey = process.env.APPLE_PRIVATE_KEY;
|
||||||
|
const clientId = process.env.APPLE_CLIENT_ID;
|
||||||
|
if (!teamId || !keyId || !privateKey || !clientId) {
|
||||||
|
throw new Error('Missing Apple OAuth env vars (APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_CLIENT_ID)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = { alg: 'ES256', kid: keyId };
|
||||||
|
const payload = { iss: teamId, iat: now, exp: now + 15777000, aud: 'https://appleid.apple.com', sub: clientId };
|
||||||
|
|
||||||
|
const encode = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||||
|
const signingInput = `${encode(header)}.${encode(payload)}`;
|
||||||
|
const key = crypto.createPrivateKey(privateKey.replace(/\\n/g, '\n'));
|
||||||
|
const sig = crypto.sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' });
|
||||||
|
|
||||||
|
return `${signingInput}.${sig.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthUrl(provider: Provider, redirect?: string): string {
|
||||||
|
const config = providers[provider];
|
||||||
|
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
|
||||||
|
const state = generateState(provider, redirect);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: getClientId(provider),
|
||||||
|
redirect_uri: getCallbackUrl(provider),
|
||||||
|
response_type: 'code',
|
||||||
|
scope: config.scopes.join(' '),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (provider === 'apple') {
|
||||||
|
params.set('response_mode', 'form_post');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${config.authUrl}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForToken(provider: Provider, code: string): Promise<string> {
|
||||||
|
const config = providers[provider];
|
||||||
|
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: getClientId(provider),
|
||||||
|
client_secret: getClientSecret(provider),
|
||||||
|
code,
|
||||||
|
redirect_uri: getCallbackUrl(provider),
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(config.tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(provider === 'github' ? { Accept: 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Token exchange failed for ${provider}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const data = await res.json() as any;
|
||||||
|
|
||||||
|
if (provider === 'apple') {
|
||||||
|
return data.id_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProviderUser(provider: Provider, token: string): Promise<ProviderUser> {
|
||||||
|
if (provider === 'apple') {
|
||||||
|
return parseAppleIdToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = providers[provider];
|
||||||
|
if (!config?.userInfoUrl) throw new Error(`No userInfo URL for ${provider}`);
|
||||||
|
|
||||||
|
const res = await fetch(config.userInfoUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch user info from ${provider}`);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const data = await res.json() as any;
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
email: data.email,
|
||||||
|
name: data.name || data.email.split('@')[0],
|
||||||
|
avatarUrl: data.picture || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'github') {
|
||||||
|
let email = data.email;
|
||||||
|
if (!email) {
|
||||||
|
const emailRes = await fetch('https://api.github.com/user/emails', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (emailRes.ok) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const emails = await emailRes.json() as any[];
|
||||||
|
const primary = emails.find((e: { primary: boolean }) => e.primary);
|
||||||
|
email = primary?.email || emails[0]?.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(data.id),
|
||||||
|
email: email || '',
|
||||||
|
name: data.name || data.login,
|
||||||
|
avatarUrl: data.avatar_url || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAppleIdToken(idToken: string): ProviderUser {
|
||||||
|
const parts = idToken.split('.');
|
||||||
|
if (parts.length !== 3) throw new Error('Invalid Apple id_token');
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email || '',
|
||||||
|
name: payload.email?.split('@')[0] || 'Apple User',
|
||||||
|
avatarUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { validateState };
|
||||||
9
packages/server/src/middleware/admin.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (!req.user || req.user.role !== 'ADMIN') {
|
||||||
|
res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: 'Admin access required' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
305
packages/server/src/routes/admin.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { Router, type Router as RouterType } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { requireAdmin } from '../middleware/admin.js';
|
||||||
|
|
||||||
|
const router: RouterType = Router();
|
||||||
|
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
function parsePagination(query: Record<string, any>, defaultLimit = 20) {
|
||||||
|
return {
|
||||||
|
page: Math.max(1, parseInt(query.page as string) || 1),
|
||||||
|
limit: Math.min(100, Math.max(1, parseInt(query.limit as string) || defaultLimit)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard Stats ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/stats', async (_req, res) => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const sevenDaysAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalUsers,
|
||||||
|
todayUsers,
|
||||||
|
totalProjects,
|
||||||
|
todayProjects,
|
||||||
|
totalCalls,
|
||||||
|
todayCalls,
|
||||||
|
callStats,
|
||||||
|
successCount,
|
||||||
|
activeProjectUsers,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.user.count({ where: { createdAt: { gte: todayStart } } }),
|
||||||
|
prisma.project.count(),
|
||||||
|
prisma.project.count({ where: { createdAt: { gte: todayStart } } }),
|
||||||
|
prisma.mcpCallLog.count(),
|
||||||
|
prisma.mcpCallLog.count({ where: { calledAt: { gte: todayStart } } }),
|
||||||
|
prisma.mcpCallLog.aggregate({
|
||||||
|
_avg: { durationMs: true },
|
||||||
|
_count: { id: true },
|
||||||
|
where: { calledAt: { gte: sevenDaysAgo } },
|
||||||
|
}),
|
||||||
|
prisma.mcpCallLog.count({
|
||||||
|
where: { calledAt: { gte: sevenDaysAgo }, success: true },
|
||||||
|
}),
|
||||||
|
prisma.mcpCallLog.groupBy({
|
||||||
|
by: ['projectId'],
|
||||||
|
where: { calledAt: { gte: sevenDaysAgo } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve unique user IDs from active projects
|
||||||
|
const activeProjectIds = activeProjectUsers.map(g => g.projectId);
|
||||||
|
let activeUserCount = 0;
|
||||||
|
if (activeProjectIds.length > 0) {
|
||||||
|
activeUserCount = await prisma.project.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { id: { in: activeProjectIds } },
|
||||||
|
}).then(r => r.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentTotal = callStats._count.id;
|
||||||
|
const successRate = recentTotal > 0 ? Math.round((successCount / recentTotal) * 100) : 100;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalUsers,
|
||||||
|
todayUsers,
|
||||||
|
totalProjects,
|
||||||
|
todayProjects,
|
||||||
|
totalCalls,
|
||||||
|
todayCalls,
|
||||||
|
avgResponseTime: Math.round(callStats._avg.durationMs ?? 0),
|
||||||
|
successRate,
|
||||||
|
activeUsers: activeUserCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Trends (7d / 30d) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/stats/trends', async (req, res) => {
|
||||||
|
const days = req.query.days === '30' ? 30 : 7;
|
||||||
|
const since = new Date();
|
||||||
|
since.setDate(since.getDate() - days);
|
||||||
|
since.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<
|
||||||
|
{ date: string; total: bigint; success_count: bigint; avg_duration: number }[]
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR("calledAt", 'YYYY-MM-DD') AS date,
|
||||||
|
COUNT(*)::bigint AS total,
|
||||||
|
SUM(CASE WHEN success THEN 1 ELSE 0 END)::bigint AS success_count,
|
||||||
|
COALESCE(AVG("durationMs"), 0)::int AS avg_duration
|
||||||
|
FROM "McpCallLog"
|
||||||
|
WHERE "calledAt" >= ${since}
|
||||||
|
GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD')
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Build full date range with zeros for missing days
|
||||||
|
const dataMap = new Map(rows.map(r => [r.date, r]));
|
||||||
|
const trends = [];
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const d = new Date(since);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
const key = d.toISOString().slice(0, 10);
|
||||||
|
const row = dataMap.get(key);
|
||||||
|
const total = row ? Number(row.total) : 0;
|
||||||
|
const successCnt = row ? Number(row.success_count) : 0;
|
||||||
|
trends.push({
|
||||||
|
date: key,
|
||||||
|
calls: total,
|
||||||
|
successRate: total > 0 ? Math.round((successCnt / total) * 100) : 100,
|
||||||
|
avgDuration: row ? row.avg_duration : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: trends });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── User Management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
const { page, limit } = parsePagination(req.query);
|
||||||
|
const search = (req.query.search as string) || '';
|
||||||
|
const sortBy = (req.query.sortBy as string) || 'createdAt';
|
||||||
|
const order = req.query.order === 'asc' ? 'asc' as const : 'desc' as const;
|
||||||
|
|
||||||
|
const where = search
|
||||||
|
? { OR: [{ name: { contains: search, mode: 'insensitive' as const } }, { email: { contains: search, mode: 'insensitive' as const } }] }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true,
|
||||||
|
_count: { select: { projects: true } },
|
||||||
|
},
|
||||||
|
orderBy: { [sortBy]: order },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ success: true, data: { users, total, page, limit } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/users/:id', async (req, res) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: {
|
||||||
|
id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true,
|
||||||
|
oauthAccounts: { select: { provider: true, createdAt: true } },
|
||||||
|
projects: {
|
||||||
|
select: { id: true, name: true, description: true, createdAt: true, _count: { select: { endpoints: true, modules: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: user });
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableSchema = z.object({ disabled: z.boolean() });
|
||||||
|
|
||||||
|
router.patch('/users/:id/disable', async (req, res) => {
|
||||||
|
const parsed = disableSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Invalid request body' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.params.id === req.user!.userId) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Cannot disable your own account' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { disabled: parsed.data.disabled },
|
||||||
|
select: { id: true, disabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: user });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Project Management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/projects', async (req, res) => {
|
||||||
|
const { page, limit } = parsePagination(req.query);
|
||||||
|
const search = (req.query.search as string) || '';
|
||||||
|
|
||||||
|
const where = search
|
||||||
|
? { name: { contains: search, mode: 'insensitive' as const } }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true, name: true, description: true, openApiVersion: true, createdAt: true,
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
_count: { select: { endpoints: true, modules: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.project.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ success: true, data: { projects, total, page, limit } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/projects/:id', async (req, res) => {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: {
|
||||||
|
id: true, name: true, description: true, baseUrl: true, openApiVersion: true, createdAt: true, updatedAt: true,
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
modules: { select: { id: true, name: true, description: true, source: true, _count: { select: { endpoints: true } } }, orderBy: { sortOrder: 'asc' } },
|
||||||
|
_count: { select: { endpoints: true, modules: true, mcpCallLogs: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: project });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/projects/:id', async (req, res) => {
|
||||||
|
await prisma.project.delete({ where: { id: req.params.id } });
|
||||||
|
res.json({ success: true, data: { message: 'Project deleted' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Call Logs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/call-logs', async (req, res) => {
|
||||||
|
const { page, limit } = parsePagination(req.query, 30);
|
||||||
|
const projectId = req.query.projectId as string | undefined;
|
||||||
|
const toolName = req.query.toolName as string | undefined;
|
||||||
|
const success = req.query.success as string | undefined;
|
||||||
|
const dateStart = req.query.dateStart as string | undefined;
|
||||||
|
const dateEnd = req.query.dateEnd as string | undefined;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (projectId) where.projectId = projectId;
|
||||||
|
if (toolName) where.toolName = toolName;
|
||||||
|
if (success === 'true') where.success = true;
|
||||||
|
if (success === 'false') where.success = false;
|
||||||
|
if (dateStart || dateEnd) {
|
||||||
|
where.calledAt = {};
|
||||||
|
if (dateStart) where.calledAt.gte = new Date(dateStart);
|
||||||
|
if (dateEnd) where.calledAt.lte = new Date(dateEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.mcpCallLog.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true, toolName: true, calledAt: true, durationMs: true, success: true,
|
||||||
|
responseSize: true, clientIp: true, estimatedTokens: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { calledAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.mcpCallLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ success: true, data: { logs, total, page, limit } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/call-logs/recent', async (_req, res) => {
|
||||||
|
const logs = await prisma.mcpCallLog.findMany({
|
||||||
|
select: {
|
||||||
|
id: true, toolName: true, calledAt: true, durationMs: true, success: true,
|
||||||
|
project: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { calledAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: logs });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -4,6 +4,8 @@ import { prisma } from '@agent-fox/shared';
|
|||||||
import { hashPassword, verifyPassword } from '../lib/password.js';
|
import { hashPassword, verifyPassword } from '../lib/password.js';
|
||||||
import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js';
|
import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { generateApiKey } from '../lib/api-key.js';
|
||||||
|
import { encryptApiKey, decryptApiKey } from '../lib/crypto.js';
|
||||||
|
|
||||||
const router: RouterType = Router();
|
const router: RouterType = Router();
|
||||||
|
|
||||||
@@ -38,8 +40,8 @@ router.post('/register', async (req, res) => {
|
|||||||
data: { email, passwordHash, name },
|
data: { email, passwordHash, name },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
|
||||||
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
|
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
@@ -57,14 +59,19 @@ router.post('/login', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash);
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
|
||||||
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
|
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/refresh', async (req, res) => {
|
router.post('/refresh', async (req, res) => {
|
||||||
@@ -81,23 +88,187 @@ router.post('/refresh', async (req, res) => {
|
|||||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
if (user.disabled) {
|
||||||
|
res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
|
||||||
res.json({ success: true, data: tokens });
|
res.json({ success: true, data: tokens });
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setPasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/set-password', requireAuth, async (req, res) => {
|
||||||
|
const parsed = setPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.passwordHash) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'ALREADY_HAS_PASSWORD', message: 'Password already set. Use change-password instead.' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(parsed.data.password);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
|
||||||
|
res.json({ success: true, data: { message: 'Password set successfully' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string(),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/change-password', requireAuth, async (req, res) => {
|
||||||
|
const parsed = changePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
|
||||||
|
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set for this account' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Current password is incorrect' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHash = await hashPassword(newPassword);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: newHash } });
|
||||||
|
res.json({ success: true, data: { message: 'Password changed' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/profile', requireAuth, async (req, res) => {
|
||||||
|
const parsed = profileSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { name: parsed.data.name },
|
||||||
|
select: { id: true, email: true, name: true },
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: user });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/me', requireAuth, async (req, res) => {
|
router.get('/me', requireAuth, async (req, res) => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: req.user!.userId },
|
where: { id: req.user!.userId },
|
||||||
select: { id: true, email: true, name: true, avatarUrl: true },
|
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true, role: true },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: user });
|
const { passwordHash, ...rest } = user;
|
||||||
|
res.json({ success: true, data: { ...rest, hasPassword: !!passwordHash } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- API Key Management ---
|
||||||
|
|
||||||
|
router.get('/api-key/status', requireAuth, async (req, res) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
select: { apiKeyPrefix: true, apiKeyHash: true },
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { hasKey: !!user?.apiKeyHash, prefix: user?.apiKeyPrefix || null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api-key/generate', requireAuth, async (req, res) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
select: { apiKeyHash: true },
|
||||||
|
});
|
||||||
|
if (user?.apiKeyHash) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'ALREADY_EXISTS', message: 'API key already exists. Use rotate to replace it.' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { raw, hash } = generateApiKey();
|
||||||
|
const encrypted = encryptApiKey(raw);
|
||||||
|
const prefix = raw.slice(0, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: { apiKey: raw } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api-key/rotate', requireAuth, async (req, res) => {
|
||||||
|
const { raw, hash } = generateApiKey();
|
||||||
|
const encrypted = encryptApiKey(raw);
|
||||||
|
const prefix = raw.slice(0, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: { apiKey: raw } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const revealSchema = z.object({ password: z.string() });
|
||||||
|
|
||||||
|
router.post('/api-key/reveal', requireAuth, async (req, res) => {
|
||||||
|
const parsed = revealSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Password is required' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
select: { passwordHash: true, apiKeyEncrypted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.passwordHash) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(parsed.data.password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Incorrect password' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.apiKeyEncrypted) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'No API key generated' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = decryptApiKey(user.apiKeyEncrypted);
|
||||||
|
res.json({ success: true, data: { apiKey } });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
30
packages/server/src/routes/fetch-spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router, type Router as RouterType } from 'express';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router: RouterType = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// CORS proxy: frontend calls this when direct fetch is blocked by CORS
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const specUrl = req.query.url as string;
|
||||||
|
if (!specUrl || !specUrl.startsWith('http')) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid URL' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(specUrl, {
|
||||||
|
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: err instanceof Error ? err.message : 'Failed to fetch URL' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,9 +7,9 @@ const router: RouterType = Router();
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
router.post('/:id/reimport', async (req, res) => {
|
router.post('/:id/reimport', async (req, res) => {
|
||||||
const { spec, specUrl } = req.body;
|
const { spec } = req.body;
|
||||||
if (!spec && !specUrl) {
|
if (!spec) {
|
||||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec or specUrl' } });
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ router.post('/:id/reimport', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = specUrl || spec;
|
const input = spec;
|
||||||
const parsed = await parseOpenApiDocument(input);
|
const parsed = await parseOpenApiDocument(input);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
|||||||
149
packages/server/src/routes/oauth.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Router, type Router as RouterType, type Response } from 'express';
|
||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { generateTokenPair } from '../lib/jwt.js';
|
||||||
|
import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState, type Provider } from '../lib/oauth-providers.js';
|
||||||
|
|
||||||
|
const router: RouterType = Router();
|
||||||
|
|
||||||
|
const VALID_PROVIDERS: Provider[] = ['google', 'github', 'apple'];
|
||||||
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
function isValidProvider(value: string): value is Provider {
|
||||||
|
return (VALID_PROVIDERS as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:provider', (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
if (!isValidProvider(provider)) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirect = req.query.redirect as string | undefined;
|
||||||
|
const url = buildAuthUrl(provider, redirect);
|
||||||
|
res.redirect(url);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: { code: 'OAUTH_ERROR', message: err instanceof Error ? err.message : 'Failed to build auth URL' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:provider/callback', async (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const params = req.query as Record<string, string>;
|
||||||
|
await handleOAuthCallback(provider, params.code, params.state, params.error, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apple sends callback as POST (form_post response mode)
|
||||||
|
router.post('/:provider/callback', async (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
await handleOAuthCallback(provider, req.body.code, req.body.state, req.body.error, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleOAuthCallback(
|
||||||
|
provider: string,
|
||||||
|
code: string | undefined,
|
||||||
|
state: string | undefined,
|
||||||
|
oauthError: string | undefined,
|
||||||
|
res: Response,
|
||||||
|
) {
|
||||||
|
if (oauthError) {
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state || !isValidProvider(provider)) {
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateResult = validateState(state, provider);
|
||||||
|
if (!stateResult.valid) {
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await exchangeCodeForToken(provider, code);
|
||||||
|
const providerUser = await fetchProviderUser(provider, token);
|
||||||
|
if (!providerUser.email) {
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findOrCreateUser(provider, providerUser);
|
||||||
|
if (user.disabled) {
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Account has been disabled')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
|
||||||
|
|
||||||
|
const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : '';
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`OAuth callback error (${provider}):`, err);
|
||||||
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateUser(
|
||||||
|
provider: string,
|
||||||
|
providerUser: { id: string; email: string; name: string; avatarUrl: string | null },
|
||||||
|
) {
|
||||||
|
const existingOAuth = await prisma.oAuthAccount.findUnique({
|
||||||
|
where: { provider_providerAccountId: { provider, providerAccountId: providerUser.id } },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
if (existingOAuth) {
|
||||||
|
if (providerUser.avatarUrl && providerUser.avatarUrl !== existingOAuth.user.avatarUrl) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: existingOAuth.user.id },
|
||||||
|
data: { avatarUrl: providerUser.avatarUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return existingOAuth.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
||||||
|
if (existingUser) {
|
||||||
|
await prisma.oAuthAccount.create({
|
||||||
|
data: { userId: existingUser.id, provider, providerAccountId: providerUser.id },
|
||||||
|
});
|
||||||
|
if (providerUser.avatarUrl && !existingUser.avatarUrl) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: existingUser.id },
|
||||||
|
data: { avatarUrl: providerUser.avatarUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return existingUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: providerUser.email,
|
||||||
|
name: providerUser.name,
|
||||||
|
avatarUrl: providerUser.avatarUrl,
|
||||||
|
passwordHash: null,
|
||||||
|
oauthAccounts: {
|
||||||
|
create: { provider, providerAccountId: providerUser.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return newUser;
|
||||||
|
} catch (err: any) {
|
||||||
|
// Handle race condition: concurrent OAuth with same email
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
||||||
|
if (user) {
|
||||||
|
await prisma.oAuthAccount.create({
|
||||||
|
data: { userId: user.id, provider, providerAccountId: providerUser.id },
|
||||||
|
}).catch(() => {}); // Ignore if OAuthAccount also raced
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,23 +2,21 @@ import { Router, type Router as RouterType } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '@agent-fox/shared';
|
import { prisma } from '@agent-fox/shared';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { generateApiKey } from '../lib/api-key.js';
|
|
||||||
import { parseOpenApiDocument } from '../services/openapi-parser.js';
|
import { parseOpenApiDocument } from '../services/openapi-parser.js';
|
||||||
|
|
||||||
const router: RouterType = Router();
|
const router: RouterType = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { spec, specUrl } = req.body;
|
const { spec } = req.body;
|
||||||
if (!spec && !specUrl) {
|
if (!spec) {
|
||||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON object) or specUrl (URL string)' } });
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = specUrl || spec;
|
const input = spec;
|
||||||
const parsed = await parseOpenApiDocument(input);
|
const parsed = await parseOpenApiDocument(input);
|
||||||
const { raw: apiKey, hash: apiKeyHash } = generateApiKey();
|
|
||||||
|
|
||||||
const project = await prisma.$transaction(async (tx) => {
|
const project = await prisma.$transaction(async (tx) => {
|
||||||
const proj = await tx.project.create({
|
const proj = await tx.project.create({
|
||||||
@@ -29,7 +27,6 @@ router.post('/', async (req, res) => {
|
|||||||
baseUrl: parsed.baseUrl,
|
baseUrl: parsed.baseUrl,
|
||||||
openApiSpec: parsed.spec as any,
|
openApiSpec: parsed.spec as any,
|
||||||
openApiVersion: parsed.openApiVersion,
|
openApiVersion: parsed.openApiVersion,
|
||||||
apiKeyHash,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +61,6 @@ router.post('/', async (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
project: { id: project.id, name: project.name },
|
project: { id: project.id, name: project.name },
|
||||||
apiKey,
|
|
||||||
stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length },
|
stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -136,17 +132,4 @@ router.delete('/:id', async (req, res) => {
|
|||||||
res.json({ success: true, data: { deleted: true } });
|
res.json({ success: true, data: { deleted: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/api-key/rotate', async (req, res) => {
|
|
||||||
const { raw, hash } = generateApiKey();
|
|
||||||
const result = await prisma.project.updateMany({
|
|
||||||
where: { id: req.params.id, userId: req.user!.userId },
|
|
||||||
data: { apiKeyHash: hash },
|
|
||||||
});
|
|
||||||
if (result.count === 0) {
|
|
||||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({ success: true, data: { apiKey: raw } });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
import type { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
||||||
|
|
||||||
type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
||||||
|
type SwaggerDoc = OpenAPIV2.Document;
|
||||||
|
|
||||||
export type ParsedModule = {
|
export type ParsedModule = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -34,27 +35,55 @@ export type ParseResult = {
|
|||||||
endpoints: ParsedEndpoint[];
|
endpoints: ParsedEndpoint[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
|
function isSwagger2(api: OpenAPI.Document): api is SwaggerDoc {
|
||||||
const rawApi = await SwaggerParser.validate(input as any);
|
return 'swagger' in api && (api as any).swagger?.startsWith('2.');
|
||||||
const api = await SwaggerParser.dereference(rawApi as any) as OpenApiDoc;
|
|
||||||
|
|
||||||
const openApiVersion = 'openapi' in api ? api.openapi : 'unknown';
|
|
||||||
const name = api.info.title;
|
|
||||||
const description = api.info.description || null;
|
|
||||||
const version = api.info.version;
|
|
||||||
const baseUrl = api.servers?.[0]?.url || null;
|
|
||||||
|
|
||||||
const tagMap = new Map<string, string | null>();
|
|
||||||
if (api.tags) {
|
|
||||||
for (const tag of api.tags) {
|
|
||||||
tagMap.set(tag.name, tag.description || null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSwagger2Endpoints(api: SwaggerDoc): { endpoints: ParsedEndpoint[]; baseUrl: string | null } {
|
||||||
|
const baseUrl = api.basePath || (api.host ? `http://${api.host}${api.basePath || ''}` : null);
|
||||||
const endpoints: ParsedEndpoint[] = [];
|
const endpoints: ParsedEndpoint[] = [];
|
||||||
const usedTags = new Set<string>();
|
|
||||||
|
|
||||||
const paths = api.paths || {};
|
const paths = api.paths || {};
|
||||||
|
|
||||||
|
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
||||||
|
if (!pathItem) continue;
|
||||||
|
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
|
||||||
|
for (const method of methods) {
|
||||||
|
const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV2.OperationObject | undefined;
|
||||||
|
if (!operation) continue;
|
||||||
|
|
||||||
|
const endpointTags = operation.tags || [];
|
||||||
|
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
|
||||||
|
const moduleName = endpointTags[0] || prefix;
|
||||||
|
|
||||||
|
// Convert Swagger 2 body parameter to requestBody-like structure
|
||||||
|
const params = (operation.parameters || []) as OpenAPIV2.Parameter[];
|
||||||
|
const bodyParam = params.find((p: any) => p.in === 'body');
|
||||||
|
const nonBodyParams = params.filter((p: any) => p.in !== 'body');
|
||||||
|
|
||||||
|
endpoints.push({
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
path: pathStr,
|
||||||
|
summary: operation.summary || null,
|
||||||
|
description: operation.description || null,
|
||||||
|
operationId: operation.operationId || null,
|
||||||
|
parameters: nonBodyParams as unknown[],
|
||||||
|
requestBody: bodyParam ? { schema: (bodyParam as any).schema } : null,
|
||||||
|
responses: (operation.responses || {}) as Record<string, unknown>,
|
||||||
|
tags: endpointTags,
|
||||||
|
deprecated: operation.deprecated || false,
|
||||||
|
moduleName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { endpoints, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[]; baseUrl: string | null } {
|
||||||
|
const baseUrl = api.servers?.[0]?.url || null;
|
||||||
|
const endpoints: ParsedEndpoint[] = [];
|
||||||
|
const paths = api.paths || {};
|
||||||
|
|
||||||
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
||||||
if (!pathItem) continue;
|
if (!pathItem) continue;
|
||||||
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
|
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
|
||||||
@@ -63,11 +92,6 @@ export async function parseOpenApiDocument(input: string | object): Promise<Pars
|
|||||||
if (!operation) continue;
|
if (!operation) continue;
|
||||||
|
|
||||||
const endpointTags = operation.tags || [];
|
const endpointTags = operation.tags || [];
|
||||||
for (const tag of endpointTags) {
|
|
||||||
usedTags.add(tag);
|
|
||||||
if (!tagMap.has(tag)) tagMap.set(tag, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
|
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
|
||||||
const moduleName = endpointTags[0] || prefix;
|
const moduleName = endpointTags[0] || prefix;
|
||||||
|
|
||||||
@@ -87,6 +111,45 @@ export async function parseOpenApiDocument(input: string | object): Promise<Pars
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { endpoints, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
|
||||||
|
// SwaggerParser.bundle handles URLs, JSON objects, and YAML strings natively
|
||||||
|
const bundled = await SwaggerParser.bundle(input as any) as OpenAPI.Document;
|
||||||
|
const api = await SwaggerParser.dereference(bundled, {
|
||||||
|
dereference: { circular: 'ignore' },
|
||||||
|
}) as OpenAPI.Document;
|
||||||
|
|
||||||
|
const isV2 = isSwagger2(api);
|
||||||
|
const openApiVersion = isV2 ? (api as SwaggerDoc).swagger : ('openapi' in api ? (api as any).openapi : 'unknown');
|
||||||
|
const name = api.info.title;
|
||||||
|
const description = api.info.description || null;
|
||||||
|
const version = api.info.version;
|
||||||
|
|
||||||
|
// Collect tags
|
||||||
|
const tagMap = new Map<string, string | null>();
|
||||||
|
if (api.tags) {
|
||||||
|
for (const tag of api.tags) {
|
||||||
|
tagMap.set(tag.name, tag.description || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse endpoints based on spec version
|
||||||
|
const { endpoints, baseUrl } = isV2
|
||||||
|
? parseSwagger2Endpoints(api as SwaggerDoc)
|
||||||
|
: parseOpenApi3Endpoints(api as OpenApiDoc);
|
||||||
|
|
||||||
|
// Track used tags
|
||||||
|
const usedTags = new Set<string>();
|
||||||
|
for (const ep of endpoints) {
|
||||||
|
for (const tag of ep.tags) {
|
||||||
|
usedTags.add(tag);
|
||||||
|
if (!tagMap.has(tag)) tagMap.set(tag, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modules
|
||||||
const modules: ParsedModule[] = [];
|
const modules: ParsedModule[] = [];
|
||||||
const moduleNames = new Set<string>();
|
const moduleNames = new Set<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@agent-fox/shared": ["../shared/dist"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { User, Project, Module, Endpoint, ModuleSource } from '@prisma/client';
|
import type { User, Project, Module, Endpoint, ModuleSource, Role } from '@prisma/client';
|
||||||
|
|
||||||
export type { User, Project, Module, Endpoint, ModuleSource };
|
export type { User, Project, Module, Endpoint, ModuleSource, Role };
|
||||||
|
|
||||||
export type ApiResponse<T = unknown> = {
|
export type ApiResponse<T = unknown> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
19
packages/web/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app/packages/web
|
||||||
|
|
||||||
|
COPY packages/web/package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY packages/web/src/ ./src/
|
||||||
|
COPY packages/web/index.html ./
|
||||||
|
COPY packages/web/vite.config.ts ./
|
||||||
|
COPY packages/web/tsconfig.json ./
|
||||||
|
COPY tsconfig.base.json /app/
|
||||||
|
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/packages/web/dist /usr/share/nginx/html
|
||||||
|
COPY packages/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<title>AgentFox</title>
|
<title>AgentFox</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
34
packages/web/nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /mcp/ {
|
||||||
|
proxy_pass http://mcp:3001;
|
||||||
|
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_http_version 1.1;
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /docs/ {
|
||||||
|
proxy_pass http://docs:80/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.96.1",
|
"@tanstack/react-query": "^5.96.1",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.2"
|
"react-router-dom": "^7.13.2"
|
||||||
|
|||||||
@@ -1,30 +1,53 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider } from './lib/auth';
|
import { AuthProvider } from './lib/auth';
|
||||||
|
import { ThemeProvider } from './lib/theme';
|
||||||
|
import { I18nProvider } from './lib/i18n';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import LoginCallback from './pages/LoginCallback';
|
||||||
import Layout from './pages/Layout';
|
import Layout from './pages/Layout';
|
||||||
import Projects from './pages/Projects';
|
import Projects from './pages/Projects';
|
||||||
import ProjectDetail from './pages/ProjectDetail';
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
|
import LandingPage from './pages/landing/LandingPage';
|
||||||
|
import AdminLayout from './pages/admin/AdminLayout';
|
||||||
|
import AdminDashboard from './pages/admin/Dashboard';
|
||||||
|
import AdminUsers from './pages/admin/Users';
|
||||||
|
import AdminUserDetail from './pages/admin/UserDetail';
|
||||||
|
import AdminProjects from './pages/admin/Projects';
|
||||||
|
import AdminProjectDetail from './pages/admin/ProjectDetail';
|
||||||
|
import AdminCallLogs from './pages/admin/CallLogs';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<I18nProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/login/callback" element={<LoginCallback />} />
|
||||||
<Route element={<Layout />}>
|
<Route path="/dashboard" element={<Layout />}>
|
||||||
<Route path="/" element={<Projects />} />
|
<Route index element={<Projects />} />
|
||||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||||
|
<Route path="projects" element={<AdminProjects />} />
|
||||||
|
<Route path="projects/:id" element={<AdminProjectDetail />} />
|
||||||
|
<Route path="logs" element={<AdminCallLogs />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</I18nProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/web/src/assets/icons/tools/antigravity.svg
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
1
packages/web/src/assets/icons/tools/claude-code.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Antigravity</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
1
packages/web/src/assets/icons/tools/codex.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
packages/web/src/assets/icons/tools/cursor.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#00D4AA" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 542 B |
1
packages/web/src/assets/icons/tools/gemini.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
1
packages/web/src/assets/icons/tools/github-copilot.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#6E40C9" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
1
packages/web/src/assets/icons/tools/openclaw.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenClaw</title><path d="M12 2.568c-6.33 0-9.495 5.275-9.495 9.495 0 4.22 3.165 8.44 6.33 9.494v2.11h2.11v-2.11s1.055.422 2.11 0v2.11h2.11v-2.11c3.165-1.055 6.33-5.274 6.33-9.494S18.33 2.568 12 2.568z" fill="url(#lobe-icons-open-claw-fill-0)"></path><path d="M3.56 9.953C.396 8.898-.66 11.008.396 13.118c1.055 2.11 3.164 1.055 4.22-1.055.632-1.477 0-2.11-1.056-2.11z" fill="url(#lobe-icons-open-claw-fill-1)"></path><path d="M20.44 9.953c3.164-1.055 4.22 1.055 3.164 3.165-1.055 2.11-3.164 1.055-4.22-1.055-.632-1.477 0-2.11 1.056-2.11z" fill="url(#lobe-icons-open-claw-fill-2)"></path><path d="M5.507 1.875c.476-.285 1.036-.233 1.615.037.577.27 1.223.774 1.937 1.488a.316.316 0 01-.447.447c-.693-.693-1.279-1.138-1.757-1.361-.475-.222-.795-.205-1.022-.069a.317.317 0 01-.326-.542zM16.877 1.913c.58-.27 1.14-.323 1.616-.038a.317.317 0 01-.326.542c-.227-.136-.547-.153-1.022.069-.478.223-1.064.668-1.756 1.361a.316.316 0 11-.448-.447c.714-.714 1.36-1.218 1.936-1.487z" fill="#FF4D4D"></path><path d="M8.835 9.109a1.266 1.266 0 100-2.532 1.266 1.266 0 000 2.532zM15.165 9.109a1.266 1.266 0 100-2.532 1.266 1.266 0 000 2.532z" fill="#050810"></path><path d="M9.046 8.16a.527.527 0 100-1.056.527.527 0 000 1.055zM15.376 8.16a.527.527 0 100-1.055.527.527 0 000 1.054z" fill="#00E5CC"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-fill-0" x1="-.659" x2="27.023" y1=".458" y2="22.855"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-fill-1" x1="0" x2="4.311" y1="9.672" y2="14.949"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-fill-2" x1="19.385" x2="24.399" y1="9.953" y2="14.462"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
64
packages/web/src/components/AuthBranding.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useI18n, tk } from '../lib/i18n';
|
||||||
|
|
||||||
|
function Logo({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBranding() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden text-center mb-8">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
||||||
|
<Logo className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.productName')}</h1>
|
||||||
|
<p className="text-[13px] text-text-muted mt-1">{t('auth.slogan')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthBranding() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden items-center justify-center p-12"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||||
|
<div className="absolute -top-24 -left-24 w-96 h-96 rounded-full opacity-10 bg-white" />
|
||||||
|
<div className="absolute -bottom-32 -right-32 w-[500px] h-[500px] rounded-full opacity-10 bg-white" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-md text-white">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center mb-8 shadow-lg">
|
||||||
|
<Logo className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
||||||
|
{t('auth.productName')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-white/90 mb-10 leading-relaxed">
|
||||||
|
{t('auth.slogan')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{['auth.feature1', 'auth.feature2', 'auth.feature3'].map((key) => (
|
||||||
|
<div key={key} className="flex items-start gap-3">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<svg className="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/90 text-[15px] leading-snug">{t(tk(key))}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/web/src/components/Badge.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
type BadgeProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'accent' | 'warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Badge({ children, variant = 'default' }: BadgeProps) {
|
||||||
|
if (['get', 'post', 'put', 'delete', 'patch'].includes(variant)) {
|
||||||
|
return (
|
||||||
|
<span className={`method-badge method-${variant}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
default: 'bg-bg-tertiary text-text-secondary',
|
||||||
|
accent: 'bg-accent-muted text-accent',
|
||||||
|
warning: 'bg-warning-muted text-warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium ${styles[variant] || styles.default}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
packages/web/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Modal from './Modal';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
type ConfirmDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmText?: string;
|
||||||
|
variant?: 'danger' | 'warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText, variant = 'danger' }: ConfirmDialogProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onCancel} size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className={`w-9 h-9 rounded-lg ${iconColor} flex items-center justify-center shrink-0`}>
|
||||||
|
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[15px] font-semibold text-text-primary">{title}</h3>
|
||||||
|
<p className="mt-1.5 text-[13px] text-text-secondary leading-relaxed">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2.5 pt-1">
|
||||||
|
<button onClick={onCancel} className="btn-ghost">{t('common.cancel')}</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||||
|
>
|
||||||
|
{confirmText ?? t('common.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
packages/web/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type EmptyStateProps = {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in">
|
||||||
|
{icon && <div className="mb-4 text-text-muted">{icon}</div>}
|
||||||
|
<h3 className="text-base font-medium text-text-primary">{title}</h3>
|
||||||
|
{description && <p className="mt-1 text-sm text-text-muted max-w-sm">{description}</p>}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
packages/web/src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import { useI18n, type Locale } from '../lib/i18n';
|
||||||
|
import { useClickOutside } from '../hooks/useClickOutside';
|
||||||
|
|
||||||
|
const languages: { locale: Locale; flag: string; label: string }[] = [
|
||||||
|
{ locale: 'en', flag: '🇺🇸', label: 'English' },
|
||||||
|
{ locale: 'zh', flag: '🇨🇳', label: '简体中文' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LanguageToggle() {
|
||||||
|
const { locale, setLocale } = useI18n();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer hover:bg-bg-tertiary"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
aria-label="Switch language"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M12 21a9 9 0 100-18 9 9 0 000 18z" />
|
||||||
|
<path d="M3.6 9h16.8M3.6 15h16.8" />
|
||||||
|
<path d="M12 3a15.3 15.3 0 014 9 15.3 15.3 0 01-4 9 15.3 15.3 0 01-4-9 15.3 15.3 0 014-9z" />
|
||||||
|
</svg>
|
||||||
|
<span className="tracking-wide">{locale === 'en' ? 'EN' : '中'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full right-0 mt-1.5 min-w-[140px] rounded-xl py-1 z-50 animate-slide-down"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<button
|
||||||
|
key={lang.locale}
|
||||||
|
onClick={() => { setLocale(lang.locale); setOpen(false); }}
|
||||||
|
className="flex items-center gap-2.5 w-full px-3 py-2 text-[13px] transition-colors cursor-pointer rounded-lg mx-0.5"
|
||||||
|
style={{
|
||||||
|
width: 'calc(100% - 4px)',
|
||||||
|
color: locale === lang.locale ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
fontWeight: locale === lang.locale ? 500 : 400,
|
||||||
|
background: locale === lang.locale ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{lang.flag}</span>
|
||||||
|
{lang.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
packages/web/src/components/Modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const widths = { sm: '384px', md: '512px', lg: '672px' };
|
||||||
|
|
||||||
|
export default function Modal({ open, onClose, children, size = 'md' }: ModalProps) {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = ref.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
else if (!open && dialog.open) dialog.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
onClose={onClose}
|
||||||
|
onClick={(e) => { if (e.target === ref.current) onClose(); }}
|
||||||
|
style={{ width: widths[size] }}
|
||||||
|
className="rounded-xl border border-border-default bg-bg-elevated p-0 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="p-6">{children}</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
packages/web/src/components/OAuthButtons.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { API_BASE } from '../lib/api';
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuthButtons({ redirectTo }: { redirectTo?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleOAuth = (provider: string) => {
|
||||||
|
const params = redirectTo ? `?redirect=${encodeURIComponent(redirectTo)}` : '';
|
||||||
|
window.location.href = `${API_BASE}/auth/oauth/${provider}${params}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{ provider: 'google', icon: GoogleIcon, label: t('auth.oauth.google') },
|
||||||
|
{ provider: 'github', icon: GitHubIcon, label: t('auth.oauth.github') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{buttons.map(({ provider, icon: Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOAuth(provider)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg border border-border-default bg-bg-primary hover:bg-bg-secondary transition-colors text-[13px] font-medium text-text-secondary cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
packages/web/src/components/SchemaView.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* Structured renderers for OpenAPI parameters, request bodies, and responses.
|
||||||
|
* Replaces raw JSON.stringify output with readable tables and schema trees.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useI18n, type TFunction } from '../lib/i18n';
|
||||||
|
|
||||||
|
/* ===== Helpers ===== */
|
||||||
|
|
||||||
|
type SchemaObj = {
|
||||||
|
type?: string;
|
||||||
|
format?: string;
|
||||||
|
description?: string;
|
||||||
|
enum?: unknown[];
|
||||||
|
items?: SchemaObj;
|
||||||
|
properties?: Record<string, SchemaObj>;
|
||||||
|
required?: string[];
|
||||||
|
additionalProperties?: boolean | SchemaObj;
|
||||||
|
oneOf?: SchemaObj[];
|
||||||
|
anyOf?: SchemaObj[];
|
||||||
|
allOf?: SchemaObj[];
|
||||||
|
default?: unknown;
|
||||||
|
example?: unknown;
|
||||||
|
nullable?: boolean;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Parameter = {
|
||||||
|
name: string;
|
||||||
|
in: string;
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
schema?: SchemaObj;
|
||||||
|
type?: string;
|
||||||
|
format?: string;
|
||||||
|
enum?: unknown[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveType(schema?: SchemaObj): string {
|
||||||
|
if (!schema) return '—';
|
||||||
|
if (schema.type === 'array' && schema.items) {
|
||||||
|
return `${resolveType(schema.items)}[]`;
|
||||||
|
}
|
||||||
|
if (schema.oneOf) return schema.oneOf.map(resolveType).join(' | ');
|
||||||
|
if (schema.anyOf) return schema.anyOf.map(resolveType).join(' | ');
|
||||||
|
return schema.type || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: string }) {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
string: 'text-[#30a46c] bg-[rgba(48,164,108,0.08)]',
|
||||||
|
integer: 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]',
|
||||||
|
number: 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]',
|
||||||
|
boolean: 'text-[#e5a000] bg-[rgba(229,160,0,0.08)]',
|
||||||
|
object: 'text-[#8b5cf6] bg-[rgba(139,92,246,0.08)]',
|
||||||
|
array: 'text-[#e5484d] bg-[rgba(229,72,77,0.08)]',
|
||||||
|
};
|
||||||
|
const base = type.replace('[]', '');
|
||||||
|
const cls = colorMap[base] || 'text-text-muted bg-bg-tertiary';
|
||||||
|
return (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-mono font-medium ${cls}`}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InBadge({ location }: { location: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-block px-1.5 py-0.5 rounded text-[11px] font-mono text-text-muted bg-bg-tertiary">
|
||||||
|
{location}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Parameters Table ===== */
|
||||||
|
|
||||||
|
export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (!Array.isArray(parameters) || parameters.length === 0) return null;
|
||||||
|
const params = parameters as Parameter[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="section-label mb-2">{t('dashboard.schema.parameters')}</p>
|
||||||
|
<div className="border border-border-default rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary/50 text-text-muted text-[11px] uppercase tracking-wider">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.name')}</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.in')}</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.type')}</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.required')}</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.descriptionCol')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-muted">
|
||||||
|
{params.map((p, i) => {
|
||||||
|
const type = resolveType(p.schema) || p.type || '—';
|
||||||
|
const format = p.schema?.format || p.format;
|
||||||
|
const enumVals = p.schema?.enum || p.enum;
|
||||||
|
return (
|
||||||
|
<tr key={i} className="hover:bg-bg-tertiary/30 transition-colors">
|
||||||
|
<td className="px-3 py-2.5 font-mono text-text-primary font-medium">
|
||||||
|
{p.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<InBadge location={p.in} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<TypeBadge type={type} />
|
||||||
|
{format && (
|
||||||
|
<span className="text-[11px] text-text-muted">({format})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{p.required ? (
|
||||||
|
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.optional')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-text-secondary max-w-xs">
|
||||||
|
<div>
|
||||||
|
{p.description && <span>{p.description}</span>}
|
||||||
|
{enumVals && enumVals.length > 0 && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
||||||
|
{enumVals.map((v, j) => (
|
||||||
|
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||||
|
{String(v)}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.schema?.default !== undefined && (
|
||||||
|
<div className="mt-0.5 text-[11px] text-text-muted">
|
||||||
|
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Schema Properties Tree ===== */
|
||||||
|
|
||||||
|
function SchemaProperties({ schema, depth = 0, t }: { schema: SchemaObj; depth?: number; t: TFunction }) {
|
||||||
|
const properties = schema.properties;
|
||||||
|
const requiredSet = new Set(schema.required || []);
|
||||||
|
|
||||||
|
if (!properties || Object.keys(properties).length === 0) {
|
||||||
|
// Just show the type if no properties
|
||||||
|
if (schema.type) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 text-[13px] text-text-muted">
|
||||||
|
<TypeBadge type={resolveType(schema)} />
|
||||||
|
{schema.description && <span className="ml-2">{schema.description}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={depth > 0 ? 'ml-4 border-l border-border-muted pl-3 mt-1' : ''}>
|
||||||
|
{Object.entries(properties).map(([name, prop]) => {
|
||||||
|
const type = resolveType(prop);
|
||||||
|
const hasChildren = prop.type === 'object' && prop.properties;
|
||||||
|
const isArray = prop.type === 'array' && prop.items?.properties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="py-1.5 first:pt-0">
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<code className="font-mono text-text-primary font-medium shrink-0">{name}</code>
|
||||||
|
<TypeBadge type={type} />
|
||||||
|
{prop.format && (
|
||||||
|
<span className="text-[11px] text-text-muted">({prop.format})</span>
|
||||||
|
)}
|
||||||
|
{requiredSet.has(name) && (
|
||||||
|
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
||||||
|
)}
|
||||||
|
{prop.nullable && (
|
||||||
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.nullable')}</span>
|
||||||
|
)}
|
||||||
|
{prop.description && (
|
||||||
|
<span className="text-text-secondary text-[12px] leading-snug">{prop.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{prop.enum && prop.enum.length > 0 && (
|
||||||
|
<div className="ml-0 mt-0.5 flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
||||||
|
{prop.enum.map((v, j) => (
|
||||||
|
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||||
|
{String(v)}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prop.default !== undefined && (
|
||||||
|
<div className="text-[11px] text-text-muted mt-0.5">
|
||||||
|
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} t={t} />}
|
||||||
|
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} t={t} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Request Body ===== */
|
||||||
|
|
||||||
|
export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (!requestBody || typeof requestBody !== 'object') return null;
|
||||||
|
const body = requestBody as {
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
content?: Record<string, { schema?: SchemaObj }>;
|
||||||
|
schema?: SchemaObj; // Swagger 2.0 converted format
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swagger 2.0 format: { schema: {...} }
|
||||||
|
if (body.schema && !body.content) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="section-label mb-2">
|
||||||
|
{t('dashboard.schema.requestBody')}
|
||||||
|
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||||
|
</p>
|
||||||
|
<div className="border border-border-default rounded-lg p-3">
|
||||||
|
<SchemaProperties schema={body.schema} t={t} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAPI 3.x format: { content: { "application/json": { schema: {...} } } }
|
||||||
|
if (!body.content) return null;
|
||||||
|
const contentTypes = Object.entries(body.content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="section-label mb-2">
|
||||||
|
{t('dashboard.schema.requestBody')}
|
||||||
|
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||||
|
</p>
|
||||||
|
{body.description && (
|
||||||
|
<p className="text-[13px] text-text-secondary mb-2">{body.description}</p>
|
||||||
|
)}
|
||||||
|
{contentTypes.map(([contentType, media]) => (
|
||||||
|
<div key={contentType} className="border border-border-default rounded-lg overflow-hidden mb-2 last:mb-0">
|
||||||
|
<div className="px-3 py-1.5 bg-bg-tertiary/50 border-b border-border-muted">
|
||||||
|
<code className="text-[11px] font-mono text-text-muted">{contentType}</code>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{media.schema ? (
|
||||||
|
media.schema.properties ? (
|
||||||
|
<SchemaProperties schema={media.schema} t={t} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-[13px]">
|
||||||
|
<TypeBadge type={resolveType(media.schema)} />
|
||||||
|
{media.schema.description && <span className="text-text-secondary">{media.schema.description}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-text-muted">{t('dashboard.schema.noSchema')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Responses ===== */
|
||||||
|
|
||||||
|
function StatusBadge({ code }: { code: string }) {
|
||||||
|
const n = parseInt(code, 10);
|
||||||
|
let cls = 'text-text-muted bg-bg-tertiary';
|
||||||
|
if (n >= 200 && n < 300) cls = 'text-[#30a46c] bg-[rgba(48,164,108,0.08)]';
|
||||||
|
else if (n >= 300 && n < 400) cls = 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]';
|
||||||
|
else if (n >= 400 && n < 500) cls = 'text-[#e5a000] bg-[rgba(229,160,0,0.08)]';
|
||||||
|
else if (n >= 500) cls = 'text-[#e5484d] bg-[rgba(229,72,77,0.08)]';
|
||||||
|
return (
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded text-[12px] font-mono font-semibold ${cls}`}>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsesView({ responses }: { responses: unknown }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (!responses || typeof responses !== 'object') return null;
|
||||||
|
const entries = Object.entries(responses as Record<string, unknown>);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="section-label mb-2">{t('dashboard.schema.responses')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map(([code, resp]) => {
|
||||||
|
const response = resp as {
|
||||||
|
description?: string;
|
||||||
|
content?: Record<string, { schema?: SchemaObj }>;
|
||||||
|
schema?: SchemaObj; // Swagger 2.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find schema from content or direct schema (Swagger 2)
|
||||||
|
let schema: SchemaObj | undefined;
|
||||||
|
let contentType: string | undefined;
|
||||||
|
if (response.content) {
|
||||||
|
const firstEntry = Object.entries(response.content)[0];
|
||||||
|
if (firstEntry) {
|
||||||
|
contentType = firstEntry[0];
|
||||||
|
schema = firstEntry[1].schema;
|
||||||
|
}
|
||||||
|
} else if (response.schema) {
|
||||||
|
schema = response.schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={code} className="border border-border-default rounded-lg overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary/50 border-b border-border-muted flex items-center gap-2.5">
|
||||||
|
<StatusBadge code={code} />
|
||||||
|
{response.description && (
|
||||||
|
<span className="text-[13px] text-text-secondary">{response.description}</span>
|
||||||
|
)}
|
||||||
|
{contentType && (
|
||||||
|
<code className="text-[11px] font-mono text-text-muted ml-auto">{contentType}</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{schema && (schema.properties || schema.items?.properties || schema.type) && (
|
||||||
|
<div className="p-3">
|
||||||
|
{schema.properties ? (
|
||||||
|
<SchemaProperties schema={schema} t={t} />
|
||||||
|
) : schema.type === 'array' && schema.items?.properties ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-text-muted mb-1">
|
||||||
|
<TypeBadge type="array" /> {t('dashboard.schema.ofObjects')}
|
||||||
|
</div>
|
||||||
|
<SchemaProperties schema={schema.items} t={t} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-[13px]">
|
||||||
|
<TypeBadge type={resolveType(schema)} />
|
||||||
|
{schema.description && <span className="text-text-secondary">{schema.description}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
packages/web/src/components/SettingsDialog.tsx
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
|
||||||
|
type ApiKeyStatus = { hasKey: boolean; prefix: string | null };
|
||||||
|
|
||||||
|
export default function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Profile state
|
||||||
|
const [name, setName] = useState(user?.name || '');
|
||||||
|
const [profileLoading, setProfileLoading] = useState(false);
|
||||||
|
const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Password state
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// API Key state
|
||||||
|
const { data: keyStatus } = useQuery({
|
||||||
|
queryKey: ['api-key-status'],
|
||||||
|
queryFn: () => apiFetch<ApiKeyStatus>('/auth/api-key/status'),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||||
|
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||||
|
const [keyLoading, setKeyLoading] = useState(false);
|
||||||
|
const [keyError, setKeyError] = useState('');
|
||||||
|
const [keyCopied, setKeyCopied] = useState(false);
|
||||||
|
const [showRotateConfirm, setShowRotateConfirm] = useState(false);
|
||||||
|
const [showPasswordPrompt, setShowPasswordPrompt] = useState<'reveal' | 'copy' | null>(null);
|
||||||
|
const [verifyPassword, setVerifyPassword] = useState('');
|
||||||
|
const [verifyError, setVerifyError] = useState('');
|
||||||
|
const [verifyLoading, setVerifyLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = dialogRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (open && !el.open) el.showModal();
|
||||||
|
else if (!open && el.open) el.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(user?.name || '');
|
||||||
|
setProfileMsg(null);
|
||||||
|
setPasswordMsg(null);
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setFreshKey(null);
|
||||||
|
setRevealedKey(null);
|
||||||
|
setKeyError('');
|
||||||
|
setKeyCopied(false);
|
||||||
|
setShowPasswordPrompt(null);
|
||||||
|
setVerifyPassword('');
|
||||||
|
setVerifyError('');
|
||||||
|
}
|
||||||
|
}, [open, user?.name]);
|
||||||
|
|
||||||
|
// Profile handlers
|
||||||
|
const handleProfileSave = async () => {
|
||||||
|
setProfileLoading(true);
|
||||||
|
setProfileMsg(null);
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ id: string; email: string; name: string }>('/auth/profile', {
|
||||||
|
method: 'PUT', body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
updateUser({ name: data.name });
|
||||||
|
setProfileMsg({ type: 'success', text: t('dashboard.settings.profileUpdated') });
|
||||||
|
setTimeout(() => setProfileMsg(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' });
|
||||||
|
} finally {
|
||||||
|
setProfileLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = async () => {
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordMsg({ type: 'error', text: t('dashboard.settings.passwordMismatch') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasswordLoading(true);
|
||||||
|
setPasswordMsg(null);
|
||||||
|
try {
|
||||||
|
await apiFetch('/auth/change-password', {
|
||||||
|
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
setPasswordMsg({ type: 'success', text: t('dashboard.settings.passwordChanged') });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setTimeout(() => setPasswordMsg(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' });
|
||||||
|
} finally {
|
||||||
|
setPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPassword = async () => {
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordMsg({ type: 'error', text: t('dashboard.settings.passwordMismatch') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasswordLoading(true);
|
||||||
|
setPasswordMsg(null);
|
||||||
|
try {
|
||||||
|
await apiFetch('/auth/set-password', {
|
||||||
|
method: 'POST', body: JSON.stringify({ password: newPassword }),
|
||||||
|
});
|
||||||
|
setPasswordMsg({ type: 'success', text: t('dashboard.settings.passwordSet') });
|
||||||
|
updateUser({ hasPassword: true });
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setTimeout(() => setPasswordMsg(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to set password' });
|
||||||
|
} finally {
|
||||||
|
setPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Key handlers
|
||||||
|
const handleGenerateKey = async () => {
|
||||||
|
setKeyLoading(true);
|
||||||
|
setKeyError('');
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ apiKey: string }>('/auth/api-key/generate', { method: 'POST' });
|
||||||
|
setFreshKey(data.apiKey);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-key-status'] });
|
||||||
|
} catch (err) {
|
||||||
|
setKeyError(err instanceof Error ? err.message : 'Failed to generate key');
|
||||||
|
} finally {
|
||||||
|
setKeyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateKey = async () => {
|
||||||
|
setShowRotateConfirm(false);
|
||||||
|
setKeyLoading(true);
|
||||||
|
setKeyError('');
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ apiKey: string }>('/auth/api-key/rotate', { method: 'POST' });
|
||||||
|
setFreshKey(data.apiKey);
|
||||||
|
setRevealedKey(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-key-status'] });
|
||||||
|
} catch (err) {
|
||||||
|
setKeyError(err instanceof Error ? err.message : 'Failed to rotate key');
|
||||||
|
} finally {
|
||||||
|
setKeyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPassword = user?.hasPassword !== false;
|
||||||
|
|
||||||
|
const handleVerifyAndAction = async () => {
|
||||||
|
setVerifyLoading(true);
|
||||||
|
setVerifyError('');
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ apiKey: string }>('/auth/api-key/reveal', {
|
||||||
|
method: 'POST', body: JSON.stringify({ password: verifyPassword }),
|
||||||
|
});
|
||||||
|
if (showPasswordPrompt === 'copy') {
|
||||||
|
navigator.clipboard.writeText(data.apiKey);
|
||||||
|
setKeyCopied(true);
|
||||||
|
setTimeout(() => setKeyCopied(false), 2000);
|
||||||
|
} else {
|
||||||
|
setRevealedKey(data.apiKey);
|
||||||
|
}
|
||||||
|
setShowPasswordPrompt(null);
|
||||||
|
setVerifyPassword('');
|
||||||
|
} catch (err) {
|
||||||
|
setVerifyError(err instanceof Error ? err.message : 'Verification failed');
|
||||||
|
} finally {
|
||||||
|
setVerifyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyFreshKey = () => {
|
||||||
|
if (freshKey) {
|
||||||
|
navigator.clipboard.writeText(freshKey);
|
||||||
|
setKeyCopied(true);
|
||||||
|
setTimeout(() => setKeyCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskedKey = keyStatus?.prefix
|
||||||
|
? `${keyStatus.prefix}${'·'.repeat(16)}` : null;
|
||||||
|
|
||||||
|
const initials = user?.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={onClose}
|
||||||
|
className="max-w-[560px] w-[90vw] rounded-2xl bg-bg-elevated border border-border-default shadow-lg p-0 backdrop:bg-overlay backdrop:backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 border-b border-border-muted flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-text-primary">{t('dashboard.settings.title')}</h2>
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||||
|
{/* Profile */}
|
||||||
|
<section>
|
||||||
|
<p className="section-title">{t('dashboard.settings.profileTitle')}</p>
|
||||||
|
<p className="section-desc mb-4">{t('dashboard.settings.profileDesc')}</p>
|
||||||
|
<div className="flex items-center gap-3.5 mb-5">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-sm font-bold tracking-wide">{initials}</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-medium text-text-primary">{user?.name}</div>
|
||||||
|
<div className="text-[13px] text-text-muted">{user?.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.displayName')}</label>
|
||||||
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
|
||||||
|
</div>
|
||||||
|
{profileMsg && (
|
||||||
|
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${profileMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||||
|
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
{profileMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||||
|
</svg>
|
||||||
|
{profileMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
|
||||||
|
{profileLoading ? t('dashboard.settings.saving') : t('dashboard.settings.saveProfile')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<section className="border-t border-border-default pt-5">
|
||||||
|
<p className="section-title">{t('dashboard.settings.apiKeyTitle')}</p>
|
||||||
|
<p className="section-desc mb-4">{t('dashboard.settings.apiKeyDesc')}</p>
|
||||||
|
|
||||||
|
{/* Fresh key display (just generated or rotated) */}
|
||||||
|
{freshKey ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||||
|
<p className="text-[13px] font-medium text-warning">{t('dashboard.settings.keySaveWarning')}</p>
|
||||||
|
</div>
|
||||||
|
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2.5">{freshKey}</code>
|
||||||
|
<button onClick={copyFreshKey} className="btn-outline w-full text-[13px]">
|
||||||
|
{keyCopied ? (
|
||||||
|
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('common.copied')}</>
|
||||||
|
) : (
|
||||||
|
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {t('dashboard.settings.copyToClipboard')}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setFreshKey(null)} className="text-[13px] text-text-muted hover:text-text-secondary transition-colors">
|
||||||
|
{t('dashboard.settings.keySaved')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !keyStatus?.hasKey ? (
|
||||||
|
/* No key generated yet */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 px-3.5 py-3 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||||
|
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||||
|
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.noKey')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleGenerateKey} disabled={keyLoading} className="btn-primary">
|
||||||
|
{keyLoading ? (
|
||||||
|
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> {t('dashboard.settings.generating')}</>
|
||||||
|
) : (
|
||||||
|
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> {t('dashboard.settings.generateKey')}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Key exists — show masked with actions */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-secondary truncate">
|
||||||
|
{revealedKey || maskedKey}
|
||||||
|
</code>
|
||||||
|
{/* Reveal button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (revealedKey) { setRevealedKey(null); }
|
||||||
|
else { setShowPasswordPrompt('reveal'); setVerifyPassword(''); setVerifyError(''); }
|
||||||
|
}}
|
||||||
|
className="btn-outline shrink-0 px-2.5"
|
||||||
|
>
|
||||||
|
{revealedKey ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* Copy button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (revealedKey) {
|
||||||
|
navigator.clipboard.writeText(revealedKey);
|
||||||
|
setKeyCopied(true);
|
||||||
|
setTimeout(() => setKeyCopied(false), 2000);
|
||||||
|
} else {
|
||||||
|
setShowPasswordPrompt('copy');
|
||||||
|
setVerifyPassword('');
|
||||||
|
setVerifyError('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="btn-outline shrink-0 px-2.5"
|
||||||
|
>
|
||||||
|
{keyCopied ? (
|
||||||
|
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPasswordPrompt && (
|
||||||
|
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||||
|
{hasPassword ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[13px] text-text-secondary">
|
||||||
|
{t('dashboard.settings.passwordPrompt', {
|
||||||
|
action: showPasswordPrompt === 'copy'
|
||||||
|
? t('dashboard.settings.passwordPromptCopy')
|
||||||
|
: t('dashboard.settings.passwordPromptReveal'),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={verifyPassword}
|
||||||
|
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
|
||||||
|
className="input-base"
|
||||||
|
placeholder={t('dashboard.settings.currentPassword')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
|
||||||
|
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordPrompt(null);
|
||||||
|
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
className="btn-primary text-[13px] py-1.5"
|
||||||
|
>
|
||||||
|
{t('dashboard.settings.setPasswordAction')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRotateConfirm(true)}
|
||||||
|
disabled={keyLoading}
|
||||||
|
className="btn-outline text-[13px]"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
{t('dashboard.settings.rotateKey')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keyError && (
|
||||||
|
<div className="mt-2 p-3 rounded-lg bg-danger-muted text-[13px] text-danger flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
{keyError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<section id="set-password-section" className="border-t border-border-default pt-5">
|
||||||
|
{hasPassword ? (
|
||||||
|
<>
|
||||||
|
<p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p>
|
||||||
|
<p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.currentPasswordLabel')}</label>
|
||||||
|
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
|
||||||
|
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
|
||||||
|
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
|
||||||
|
</div>
|
||||||
|
{passwordMsg && (
|
||||||
|
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||||
|
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||||
|
</svg>
|
||||||
|
{passwordMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handlePasswordChange}
|
||||||
|
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="section-title">{t('dashboard.settings.setPasswordTitle')}</p>
|
||||||
|
<p className="section-desc mb-4">{t('dashboard.settings.setPasswordDesc')}</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
|
||||||
|
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
|
||||||
|
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
|
||||||
|
</div>
|
||||||
|
{passwordMsg && (
|
||||||
|
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||||
|
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||||
|
</svg>
|
||||||
|
{passwordMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSetPassword}
|
||||||
|
disabled={passwordLoading || !newPassword || newPassword.length < 8 || newPassword !== confirmPassword}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{passwordLoading ? t('dashboard.settings.settingPassword') : t('dashboard.settings.setPassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showRotateConfirm}
|
||||||
|
onCancel={() => setShowRotateConfirm(false)}
|
||||||
|
onConfirm={handleRotateKey}
|
||||||
|
title={t('dashboard.settings.rotateTitle')}
|
||||||
|
description={t('dashboard.settings.rotateDesc')}
|
||||||
|
confirmText={t('dashboard.settings.rotateConfirm')}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
packages/web/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
type SkeletonProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
|
||||||
|
return <div className={`skeleton ${className}`} />;
|
||||||
|
}
|
||||||
82
packages/web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useRef, useCallback, type ReactNode } from 'react';
|
||||||
|
import { useTheme } from '../lib/theme';
|
||||||
|
import { useI18n, type TranslationKey } from '../lib/i18n';
|
||||||
|
import { useClickOutside } from '../hooks/useClickOutside';
|
||||||
|
|
||||||
|
const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: ReactNode }> = [
|
||||||
|
{
|
||||||
|
key: 'light',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dark',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8m-4-4v4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
|
||||||
|
|
||||||
|
const current = themes.find(th => th.key === theme)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer hover:bg-bg-tertiary"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
aria-label="Switch theme"
|
||||||
|
>
|
||||||
|
{current.icon}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full right-0 mt-1.5 min-w-[140px] rounded-xl py-1 z-50 animate-slide-down"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{themes.map(th => (
|
||||||
|
<button
|
||||||
|
key={th.key}
|
||||||
|
onClick={() => { setTheme(th.key); setOpen(false); }}
|
||||||
|
className="flex items-center gap-2.5 w-full px-3 py-2 text-[13px] transition-colors cursor-pointer rounded-lg mx-0.5"
|
||||||
|
style={{
|
||||||
|
width: 'calc(100% - 4px)',
|
||||||
|
color: theme === th.key ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
fontWeight: theme === th.key ? 500 : 400,
|
||||||
|
background: theme === th.key ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="leading-none">{th.icon}</span>
|
||||||
|
{t(`theme.${th.key}` as TranslationKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/web/src/hooks/useClickOutside.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react';
|
||||||
|
|
||||||
|
export function useClickOutside(ref: RefObject<HTMLElement | null>, onClose: () => void, active: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [active, ref, onClose]);
|
||||||
|
}
|
||||||
25
packages/web/src/hooks/useScrollReveal.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useScrollReveal(options?: IntersectionObserverInit) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.15, ...options }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- options is expected to be static per call site
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ref, isVisible };
|
||||||
|
}
|
||||||
@@ -1 +1,416 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ===== Theme Variables ===== */
|
||||||
|
:root {
|
||||||
|
/* Light theme (default) */
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8f9fb;
|
||||||
|
--bg-tertiary: #f0f1f4;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--bg-inset: #e8e9ed;
|
||||||
|
--bg-sidebar: #fafbfc;
|
||||||
|
--border-default: #e2e4e9;
|
||||||
|
--border-muted: #eef0f3;
|
||||||
|
--border-strong: #cdd0d5;
|
||||||
|
--text-primary: #0f1115;
|
||||||
|
--text-secondary: #4a4f5a;
|
||||||
|
--text-muted: #868c98;
|
||||||
|
--text-inverted: #ffffff;
|
||||||
|
--accent: #d97706;
|
||||||
|
--accent-hover: #b45309;
|
||||||
|
--accent-subtle: #fef3c7;
|
||||||
|
--accent-muted: rgba(217, 119, 6, 0.1);
|
||||||
|
--danger: #e5484d;
|
||||||
|
--danger-muted: rgba(229, 72, 77, 0.08);
|
||||||
|
--success: #30a46c;
|
||||||
|
--success-muted: rgba(48, 164, 108, 0.08);
|
||||||
|
--warning: #e5a000;
|
||||||
|
--warning-muted: rgba(229, 160, 0, 0.08);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.02);
|
||||||
|
--code-bg: #1a1b26;
|
||||||
|
--code-text: #9ece6a;
|
||||||
|
--code-comment: #565f89;
|
||||||
|
--code-keyword: #bb9af7;
|
||||||
|
--overlay: rgba(0, 0, 0, 0.4);
|
||||||
|
--fox-amber: #f59e0b;
|
||||||
|
--fox-orange: #ea580c;
|
||||||
|
--fox-glow: rgba(245, 158, 11, 0.15);
|
||||||
|
--method-get: #30a46c;
|
||||||
|
--method-get-bg: rgba(48, 164, 108, 0.1);
|
||||||
|
--method-post: #3b82f6;
|
||||||
|
--method-post-bg: rgba(59, 130, 246, 0.1);
|
||||||
|
--method-put: #e5a000;
|
||||||
|
--method-put-bg: rgba(229, 160, 0, 0.1);
|
||||||
|
--method-delete: #e5484d;
|
||||||
|
--method-delete-bg: rgba(229, 72, 77, 0.1);
|
||||||
|
--method-patch: #8b5cf6;
|
||||||
|
--method-patch-bg: rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg-primary: #0a0a0c;
|
||||||
|
--bg-secondary: #101012;
|
||||||
|
--bg-tertiary: #18181b;
|
||||||
|
--bg-elevated: #1a1a1e;
|
||||||
|
--bg-inset: #232326;
|
||||||
|
--bg-sidebar: #0e0e10;
|
||||||
|
--border-default: #27272a;
|
||||||
|
--border-muted: #1e1e21;
|
||||||
|
--border-strong: #3f3f46;
|
||||||
|
--text-primary: #ececef;
|
||||||
|
--text-secondary: #a0a0ab;
|
||||||
|
--text-muted: #63636e;
|
||||||
|
--text-inverted: #0a0a0c;
|
||||||
|
--accent: #fbbf24;
|
||||||
|
--accent-hover: #f59e0b;
|
||||||
|
--accent-subtle: rgba(251, 191, 36, 0.08);
|
||||||
|
--accent-muted: rgba(251, 191, 36, 0.12);
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-muted: rgba(248, 113, 113, 0.1);
|
||||||
|
--success: #4ade80;
|
||||||
|
--success-muted: rgba(74, 222, 128, 0.1);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-muted: rgba(251, 191, 36, 0.1);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
|
||||||
|
--code-bg: #0c0c0f;
|
||||||
|
--code-text: #9ece6a;
|
||||||
|
--code-comment: #565f89;
|
||||||
|
--code-keyword: #bb9af7;
|
||||||
|
--overlay: rgba(0, 0, 0, 0.65);
|
||||||
|
--fox-amber: #fbbf24;
|
||||||
|
--fox-orange: #f97316;
|
||||||
|
--fox-glow: rgba(251, 191, 36, 0.2);
|
||||||
|
--method-get: #4ade80;
|
||||||
|
--method-get-bg: rgba(74, 222, 128, 0.12);
|
||||||
|
--method-post: #60a5fa;
|
||||||
|
--method-post-bg: rgba(96, 165, 250, 0.12);
|
||||||
|
--method-put: #fbbf24;
|
||||||
|
--method-put-bg: rgba(251, 191, 36, 0.12);
|
||||||
|
--method-delete: #f87171;
|
||||||
|
--method-delete-bg: rgba(248, 113, 113, 0.12);
|
||||||
|
--method-patch: #a78bfa;
|
||||||
|
--method-patch-bg: rgba(167, 139, 250, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-primary: #0a0a0c;
|
||||||
|
--bg-secondary: #101012;
|
||||||
|
--bg-tertiary: #18181b;
|
||||||
|
--bg-elevated: #1a1a1e;
|
||||||
|
--bg-inset: #232326;
|
||||||
|
--bg-sidebar: #0e0e10;
|
||||||
|
--border-default: #27272a;
|
||||||
|
--border-muted: #1e1e21;
|
||||||
|
--border-strong: #3f3f46;
|
||||||
|
--text-primary: #ececef;
|
||||||
|
--text-secondary: #a0a0ab;
|
||||||
|
--text-muted: #63636e;
|
||||||
|
--text-inverted: #0a0a0c;
|
||||||
|
--accent: #fbbf24;
|
||||||
|
--accent-hover: #f59e0b;
|
||||||
|
--accent-subtle: rgba(251, 191, 36, 0.08);
|
||||||
|
--accent-muted: rgba(251, 191, 36, 0.12);
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-muted: rgba(248, 113, 113, 0.1);
|
||||||
|
--success: #4ade80;
|
||||||
|
--success-muted: rgba(74, 222, 128, 0.1);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-muted: rgba(251, 191, 36, 0.1);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
|
||||||
|
--code-bg: #0c0c0f;
|
||||||
|
--code-text: #9ece6a;
|
||||||
|
--code-comment: #565f89;
|
||||||
|
--code-keyword: #bb9af7;
|
||||||
|
--overlay: rgba(0, 0, 0, 0.65);
|
||||||
|
--fox-amber: #fbbf24;
|
||||||
|
--fox-orange: #f97316;
|
||||||
|
--fox-glow: rgba(251, 191, 36, 0.2);
|
||||||
|
--method-get: #4ade80;
|
||||||
|
--method-get-bg: rgba(74, 222, 128, 0.12);
|
||||||
|
--method-post: #60a5fa;
|
||||||
|
--method-post-bg: rgba(96, 165, 250, 0.12);
|
||||||
|
--method-put: #fbbf24;
|
||||||
|
--method-put-bg: rgba(251, 191, 36, 0.12);
|
||||||
|
--method-delete: #f87171;
|
||||||
|
--method-delete-bg: rgba(248, 113, 113, 0.12);
|
||||||
|
--method-patch: #a78bfa;
|
||||||
|
--method-patch-bg: rgba(167, 139, 250, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tailwind Theme ===== */
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||||
|
--font-display: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--font-heading: 'Outfit', 'DM Sans', system-ui, sans-serif;
|
||||||
|
|
||||||
|
--color-bg-primary: var(--bg-primary);
|
||||||
|
--color-bg-secondary: var(--bg-secondary);
|
||||||
|
--color-bg-tertiary: var(--bg-tertiary);
|
||||||
|
--color-bg-elevated: var(--bg-elevated);
|
||||||
|
--color-bg-inset: var(--bg-inset);
|
||||||
|
--color-bg-sidebar: var(--bg-sidebar);
|
||||||
|
--color-border-default: var(--border-default);
|
||||||
|
--color-border-muted: var(--border-muted);
|
||||||
|
--color-border-strong: var(--border-strong);
|
||||||
|
--color-text-primary: var(--text-primary);
|
||||||
|
--color-text-secondary: var(--text-secondary);
|
||||||
|
--color-text-muted: var(--text-muted);
|
||||||
|
--color-text-inverted: var(--text-inverted);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-hover: var(--accent-hover);
|
||||||
|
--color-accent-subtle: var(--accent-subtle);
|
||||||
|
--color-accent-muted: var(--accent-muted);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-danger-muted: var(--danger-muted);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-muted: var(--success-muted);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-muted: var(--warning-muted);
|
||||||
|
--color-code-bg: var(--code-bg);
|
||||||
|
--color-code-text: var(--code-text);
|
||||||
|
--color-overlay: var(--overlay);
|
||||||
|
--color-fox-amber: var(--fox-amber);
|
||||||
|
--color-fox-orange: var(--fox-orange);
|
||||||
|
--color-fox-glow: var(--fox-glow);
|
||||||
|
|
||||||
|
--shadow-sm: var(--shadow-sm);
|
||||||
|
--shadow-md: var(--shadow-md);
|
||||||
|
--shadow-lg: var(--shadow-lg);
|
||||||
|
|
||||||
|
--animate-fade-in: fade-in 0.2s ease-out both;
|
||||||
|
--animate-slide-up: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
--animate-slide-down: slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
--animate-shimmer: shimmer 1.8s ease-in-out infinite;
|
||||||
|
--animate-pulse-soft: pulse-soft 2s ease-in-out infinite;
|
||||||
|
--animate-float: float 6s ease-in-out infinite;
|
||||||
|
--animate-gradient-shift: gradient-shift 8s ease-in-out infinite;
|
||||||
|
--animate-reveal-up: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
--animate-marquee: marquee 30s linear infinite;
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(10px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
@keyframes pulse-soft {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
@keyframes slide-down {
|
||||||
|
from { opacity: 0; transform: translateY(-4px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-20px); }
|
||||||
|
}
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0%, 100% { opacity: 0.6; transform: scale(1) translate(0, 0); }
|
||||||
|
33% { opacity: 0.8; transform: scale(1.1) translate(10px, -10px); }
|
||||||
|
66% { opacity: 0.5; transform: scale(0.95) translate(-10px, 10px); }
|
||||||
|
}
|
||||||
|
@keyframes reveal-up {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes marquee {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
@keyframes typing {
|
||||||
|
from { width: 0; }
|
||||||
|
to { width: 100%; }
|
||||||
|
}
|
||||||
|
@keyframes blink-caret {
|
||||||
|
0%, 100% { border-color: transparent; }
|
||||||
|
50% { border-color: var(--fox-amber); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Base ===== */
|
||||||
|
body {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 99px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ===== Component Utilities ===== */
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
&:hover { background: var(--accent-hover); transform: translateY(-0.5px); }
|
||||||
|
&:active { transform: translateY(0); }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
&:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
&:hover { opacity: 0.9; transform: translateY(-0.5px); }
|
||||||
|
&:active { transform: translateY(0); }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||||
|
}
|
||||||
|
.btn-outline {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
&:hover { border-color: var(--border-strong); color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
|
}
|
||||||
|
.input-base {
|
||||||
|
@apply w-full px-3.5 py-2.5 rounded-lg text-sm transition-all duration-150 outline-none;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
&::placeholder { color: var(--text-muted); }
|
||||||
|
&:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); }
|
||||||
|
}
|
||||||
|
select.input-base {
|
||||||
|
@apply pr-10 appearance-none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl transition-all duration-200;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.card-hover {
|
||||||
|
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-inset) 50%, var(--bg-tertiary) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.code-block {
|
||||||
|
@apply rounded-lg p-4 text-sm font-mono overflow-auto relative;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-text);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.section-label {
|
||||||
|
@apply text-[11px] font-semibold uppercase tracking-[0.08em];
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
@apply text-sm font-semibold;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.section-desc {
|
||||||
|
@apply text-[13px] mt-0.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
@apply px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 cursor-pointer;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
&:hover { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== User Dropdown ===== */
|
||||||
|
.user-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 100;
|
||||||
|
animation: slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Method Badges ===== */
|
||||||
|
.method-badge {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold font-mono tracking-wide;
|
||||||
|
}
|
||||||
|
.method-get { background: var(--method-get-bg); color: var(--method-get); }
|
||||||
|
.method-post { background: var(--method-post-bg); color: var(--method-post); }
|
||||||
|
.method-put { background: var(--method-put-bg); color: var(--method-put); }
|
||||||
|
.method-delete { background: var(--method-delete-bg); color: var(--method-delete); }
|
||||||
|
.method-patch { background: var(--method-patch-bg); color: var(--method-patch); }
|
||||||
|
|
||||||
|
/* ===== Dialog ===== */
|
||||||
|
dialog {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-height: calc(100vh - 4rem);
|
||||||
|
}
|
||||||
|
dialog[open] {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
animation: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: var(--overlay);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Staggered children animation ===== */
|
||||||
|
.stagger-children > * {
|
||||||
|
animation: fade-in 0.3s ease-out both;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
|
||||||
|
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
|
||||||
|
.stagger-children > *:nth-child(4) { animation-delay: 120ms; }
|
||||||
|
.stagger-children > *:nth-child(5) { animation-delay: 160ms; }
|
||||||
|
.stagger-children > *:nth-child(6) { animation-delay: 200ms; }
|
||||||
|
.stagger-children > *:nth-child(7) { animation-delay: 240ms; }
|
||||||
|
.stagger-children > *:nth-child(8) { animation-delay: 280ms; }
|
||||||
|
.stagger-children > *:nth-child(n+9) { animation-delay: 320ms; }
|
||||||
|
|
||||||
|
/* ===== Reduced Motion ===== */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = '/api';
|
export const API_BASE = '/api';
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -66,7 +66,13 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const json: ApiResponse<T> = await res.json();
|
const text = await res.text();
|
||||||
|
let json: ApiResponse<T>;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Server error (${res.status})`);
|
||||||
|
}
|
||||||
if (!json.success) {
|
if (!json.success) {
|
||||||
throw new Error(json.error?.message || 'Request failed');
|
throw new Error(json.error?.message || 'Request failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
|
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
|
||||||
|
|
||||||
type User = { id: string; email: string; name: string };
|
type User = { id: string; email: string; name: string; hasPassword?: boolean; role?: string };
|
||||||
|
|
||||||
type AuthContextType = {
|
type AuthContextType = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -9,6 +9,8 @@ type AuthContextType = {
|
|||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (email: string, password: string, name: string) => Promise<void>;
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
updateUser: (updates: Partial<User>) => void;
|
||||||
|
loginWithTokens: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
@@ -48,8 +50,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = () => { clearTokens(); setUser(null); };
|
const logout = () => { clearTokens(); setUser(null); };
|
||||||
|
|
||||||
|
const updateUser = (updates: Partial<User>) => {
|
||||||
|
setUser(prev => prev ? { ...prev, ...updates } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginWithTokens = async (access: string, refresh: string) => {
|
||||||
|
setTokens(access, refresh);
|
||||||
|
const user = await apiFetch<User>('/auth/me');
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser, loginWithTokens }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
32
packages/web/src/lib/fetch-spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import yaml from 'js-yaml';
|
||||||
|
import { apiFetch } from './api';
|
||||||
|
|
||||||
|
function parseSpecText(text: string): object {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return yaml.load(text) as object;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an OpenAPI spec from a URL and parse it.
|
||||||
|
* 1. Try direct fetch from browser (works for localhost/intranet)
|
||||||
|
* 2. If CORS blocks it, fall back to server-side proxy
|
||||||
|
* Returns a parsed spec object (JSON or YAML).
|
||||||
|
*/
|
||||||
|
export async function fetchSpecFromUrl(url: string): Promise<object> {
|
||||||
|
// Try direct fetch first (handles localhost, intranet, CORS-friendly APIs)
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
|
||||||
|
});
|
||||||
|
if (res.ok) return parseSpecText(await res.text());
|
||||||
|
} catch {
|
||||||
|
// CORS or network error — fall through to server proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to server-side proxy for CORS-restricted URLs
|
||||||
|
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(url)}`);
|
||||||
|
return parseSpecText(data.content);
|
||||||
|
}
|
||||||
62
packages/web/src/lib/i18n.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||||
|
import en from './i18n/en';
|
||||||
|
import zh from './i18n/zh';
|
||||||
|
|
||||||
|
export type Locale = 'en' | 'zh';
|
||||||
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|
||||||
|
type AllTranslations = Record<Locale, Record<TranslationKey, string>>;
|
||||||
|
|
||||||
|
const translations: AllTranslations = { en, zh };
|
||||||
|
|
||||||
|
/** Use `tk()` to cast dynamic key strings (e.g. template literals) to TranslationKey */
|
||||||
|
export const tk = (key: string) => key as TranslationKey;
|
||||||
|
|
||||||
|
export type TFunction = (key: TranslationKey, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
type I18nContextType = {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (l: Locale) => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextType | null>(null);
|
||||||
|
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
const saved = localStorage.getItem('agent-fox-locale');
|
||||||
|
if (saved === 'en' || saved === 'zh') return saved;
|
||||||
|
return navigator.language.startsWith('zh') ? 'zh' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(detectLocale);
|
||||||
|
|
||||||
|
const setLocale = useCallback((l: Locale) => {
|
||||||
|
setLocaleState(l);
|
||||||
|
localStorage.setItem('agent-fox-locale', l);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const t = useCallback((key: TranslationKey, params?: Record<string, string | number>): string => {
|
||||||
|
let text = translations[locale][key] ?? key;
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
text = text.replaceAll(`{${k}}`, String(v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
387
packages/web/src/lib/i18n/en.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
const en = {
|
||||||
|
// ===== Landing Page =====
|
||||||
|
|
||||||
|
// Nav
|
||||||
|
'nav.features': 'Features',
|
||||||
|
'nav.tools': 'Tools',
|
||||||
|
'nav.testimonials': 'Testimonials',
|
||||||
|
'nav.pricing': 'Pricing',
|
||||||
|
'nav.faq': 'FAQ',
|
||||||
|
'nav.signIn': 'Sign In',
|
||||||
|
'nav.getStarted': 'Get Started',
|
||||||
|
'nav.dashboard': 'Dashboard',
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
'hero.badge': 'MCP-Powered API Intelligence',
|
||||||
|
'hero.title': 'API Docs for LLMs,',
|
||||||
|
'hero.titleHighlight': 'Done Right',
|
||||||
|
'hero.subtitle': 'Let AI agents query your OpenAPI documentation with surgical precision. Multi-level retrieval serves exactly the tokens needed — not the entire spec.',
|
||||||
|
'hero.cta': 'Start Free',
|
||||||
|
'hero.ctaSecondary': 'View Documentation',
|
||||||
|
'hero.terminal.comment': '# Connect your AI tool to any API documentation',
|
||||||
|
'hero.terminal.cmd1': 'get_project_overview',
|
||||||
|
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
|
||||||
|
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
|
||||||
|
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
|
||||||
|
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
|
||||||
|
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
|
||||||
|
|
||||||
|
// Features
|
||||||
|
'features.label': 'Features',
|
||||||
|
'features.title': 'Intelligent API Retrieval',
|
||||||
|
'features.subtitle': 'Five specialized MCP tools designed for minimal token usage per call',
|
||||||
|
'features.progressive.title': 'Progressive Drill-Down',
|
||||||
|
'features.progressive.desc': 'Navigate from project overview to module list to endpoint detail — retrieve only what you need.',
|
||||||
|
'features.token.title': 'Token Efficient',
|
||||||
|
'features.token.desc': '~200-2,000 tokens per call vs 10,000+ for dumping the full OpenAPI spec into context.',
|
||||||
|
'features.spec.title': 'Full Spec Support',
|
||||||
|
'features.spec.desc': 'Import OpenAPI 3.x and Swagger 2.0 docs. All $refs are dereferenced automatically.',
|
||||||
|
'features.import.title': 'One-Click Import',
|
||||||
|
'features.import.desc': 'Paste a URL or upload JSON/YAML — your API docs are parsed and indexed instantly.',
|
||||||
|
'features.projects.title': 'Multi-Project',
|
||||||
|
'features.projects.desc': 'Organize APIs into isolated projects, each with its own MCP endpoint and API key.',
|
||||||
|
'features.security.title': 'Secure by Default',
|
||||||
|
'features.security.desc': 'Project-level API keys with bcrypt hashing. JWT auth for the dashboard. Zero shared secrets.',
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
'tools.label': 'Compatibility',
|
||||||
|
'tools.title': 'Works with Your Favorite AI Tools',
|
||||||
|
'tools.subtitle': 'AgentFox speaks MCP — the universal protocol supported by leading AI coding assistants',
|
||||||
|
'tools.claude.name': 'Claude Code',
|
||||||
|
'tools.claude.desc': 'Anthropic CLI',
|
||||||
|
'tools.codex.name': 'Codex',
|
||||||
|
'tools.codex.desc': 'OpenAI CLI',
|
||||||
|
'tools.cursor.name': 'Cursor',
|
||||||
|
'tools.cursor.desc': 'AI Code Editor',
|
||||||
|
'tools.copilot.name': 'GitHub Copilot',
|
||||||
|
'tools.copilot.desc': 'GitHub AI Pair',
|
||||||
|
'tools.gemini.name': 'Gemini CLI',
|
||||||
|
'tools.gemini.desc': 'Google AI CLI',
|
||||||
|
'tools.antigravity.name': 'Antigravity',
|
||||||
|
'tools.antigravity.desc': 'AI Dev Platform',
|
||||||
|
'tools.openclaw.name': 'OpenClaw',
|
||||||
|
'tools.openclaw.desc': 'AI Dev Platform',
|
||||||
|
|
||||||
|
// Testimonials
|
||||||
|
'testimonials.label': 'Testimonials',
|
||||||
|
'testimonials.title': 'Loved by Developers',
|
||||||
|
'testimonials.1.quote': 'AgentFox cut our API integration time in half. Instead of copy-pasting docs, Claude just queries what it needs through MCP.',
|
||||||
|
'testimonials.1.name': 'Sarah Chen',
|
||||||
|
'testimonials.1.role': 'Staff Engineer at Vercel',
|
||||||
|
'testimonials.2.quote': 'The token savings are real — our Cursor workflow went from burning 15K tokens per API call to under 1K. Game changer for complex integrations.',
|
||||||
|
'testimonials.2.name': 'Marcus Rivera',
|
||||||
|
'testimonials.2.role': 'CTO at Stackblitz',
|
||||||
|
'testimonials.3.quote': 'We onboarded 50+ internal APIs to AgentFox in a week. Now every team\'s AI assistant can discover and use any service endpoint.',
|
||||||
|
'testimonials.3.name': 'Yuki Tanaka',
|
||||||
|
'testimonials.3.role': 'Platform Lead at Shopify',
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
'pricing.label': 'Pricing',
|
||||||
|
'pricing.title': 'Simple, Transparent Pricing',
|
||||||
|
'pricing.subtitle': 'Start free, scale as you grow',
|
||||||
|
'pricing.free.name': 'Free',
|
||||||
|
'pricing.free.price': '$0',
|
||||||
|
'pricing.free.period': '/month',
|
||||||
|
'pricing.free.desc': 'Perfect for trying out MCP-powered API docs',
|
||||||
|
'pricing.free.f1': '1 project',
|
||||||
|
'pricing.free.f2': '100 MCP queries/day',
|
||||||
|
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
|
||||||
|
'pricing.free.f4': 'Community support',
|
||||||
|
'pricing.free.cta': 'Get Started',
|
||||||
|
'pricing.pro.name': 'Pro',
|
||||||
|
'pricing.pro.price': '$29',
|
||||||
|
'pricing.pro.period': '/month',
|
||||||
|
'pricing.pro.badge': 'Most Popular',
|
||||||
|
'pricing.pro.desc': 'For teams shipping with AI-assisted development',
|
||||||
|
'pricing.pro.f1': 'Unlimited projects',
|
||||||
|
'pricing.pro.f2': 'Unlimited MCP queries',
|
||||||
|
'pricing.pro.f3': 'Priority import queue',
|
||||||
|
'pricing.pro.f4': 'Team collaboration',
|
||||||
|
'pricing.pro.f5': 'Priority support',
|
||||||
|
'pricing.pro.cta': 'Start Free Trial',
|
||||||
|
'pricing.enterprise.name': 'Enterprise',
|
||||||
|
'pricing.enterprise.price': 'Custom',
|
||||||
|
'pricing.enterprise.period': '',
|
||||||
|
'pricing.enterprise.desc': 'For organizations with advanced requirements',
|
||||||
|
'pricing.enterprise.f1': 'Self-hosted deployment',
|
||||||
|
'pricing.enterprise.f2': 'SSO / SAML',
|
||||||
|
'pricing.enterprise.f3': 'SLA guarantee',
|
||||||
|
'pricing.enterprise.f4': 'Dedicated support',
|
||||||
|
'pricing.enterprise.f5': 'Custom integrations',
|
||||||
|
'pricing.enterprise.cta': 'Contact Sales',
|
||||||
|
|
||||||
|
// FAQ
|
||||||
|
'faq.label': 'FAQ',
|
||||||
|
'faq.title': 'Frequently Asked Questions',
|
||||||
|
'faq.1.q': 'What is MCP and how does AgentFox use it?',
|
||||||
|
'faq.1.a': 'MCP (Model Context Protocol) is an open standard that lets AI assistants connect to external tools and data sources. AgentFox exposes your API documentation through MCP tools, so AI coding assistants like Claude Code, Cursor, and Copilot can query endpoint details on demand — without dumping the entire spec into their context window.',
|
||||||
|
'faq.2.q': 'Which OpenAPI formats are supported?',
|
||||||
|
'faq.2.a': 'AgentFox supports both OpenAPI 3.x and Swagger 2.0 specifications. You can import documents in JSON or YAML format, or provide a URL to fetch them directly. All $ref references are automatically dereferenced during import.',
|
||||||
|
'faq.3.q': 'How much does it reduce token usage?',
|
||||||
|
'faq.3.a': 'Each MCP tool call returns ~200-2,000 tokens of focused information, compared to 10,000+ tokens for dumping a full API spec. For a typical integration task, this means 80-95% reduction in token consumption.',
|
||||||
|
'faq.4.q': 'Is my API documentation secure?',
|
||||||
|
'faq.4.a': 'Yes. Each project has its own API key (bcrypt hashed, never stored in plain text). The MCP endpoint requires authentication for every request. User dashboard access uses JWT with automatic token rotation.',
|
||||||
|
'faq.5.q': 'Which AI tools are compatible?',
|
||||||
|
'faq.5.a': 'Any tool that supports the MCP protocol can connect to AgentFox. This includes Claude Code, OpenAI Codex CLI, OpenClaw, Gemini CLI, Cursor, GitHub Copilot (via MCP plugins), Antigravity, and more. If your tool supports MCP, it works with AgentFox.',
|
||||||
|
'faq.6.q': 'Can I self-host AgentFox?',
|
||||||
|
'faq.6.a': 'Yes! AgentFox is designed for both cloud and self-hosted deployment. The Enterprise plan includes full self-hosted support with Docker Compose, along with SSO integration and dedicated support.',
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
'footer.product': 'Product',
|
||||||
|
'footer.features': 'Features',
|
||||||
|
'footer.pricing': 'Pricing',
|
||||||
|
'footer.docs': 'Documentation',
|
||||||
|
'footer.changelog': 'Changelog',
|
||||||
|
'footer.resources': 'Resources',
|
||||||
|
'footer.github': 'GitHub',
|
||||||
|
'footer.community': 'Community',
|
||||||
|
'footer.blog': 'Blog',
|
||||||
|
'footer.legal': 'Legal',
|
||||||
|
'footer.privacy': 'Privacy',
|
||||||
|
'footer.terms': 'Terms',
|
||||||
|
'footer.copyright': '© 2026 AgentFox. All rights reserved.',
|
||||||
|
'footer.tagline': 'MCP-powered API documentation for AI agents.',
|
||||||
|
|
||||||
|
// ===== Common =====
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.confirm': 'Confirm',
|
||||||
|
'common.delete': 'Delete',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.back': 'Back',
|
||||||
|
'common.done': 'Done',
|
||||||
|
'common.copy': 'Copy',
|
||||||
|
'common.copied': 'Copied',
|
||||||
|
'common.continue': 'Continue',
|
||||||
|
'common.import': 'Import',
|
||||||
|
'common.importing': 'Importing...',
|
||||||
|
'common.signOut': 'Sign Out',
|
||||||
|
'common.signOutConfirm': 'Are you sure you want to sign out?',
|
||||||
|
'common.settings': 'Settings',
|
||||||
|
'common.modules': 'Modules',
|
||||||
|
'common.endpoints': 'Endpoints',
|
||||||
|
'common.total': 'total',
|
||||||
|
'common.add': 'Add',
|
||||||
|
'common.fromUrl': 'From URL',
|
||||||
|
'common.uploadFile': 'Upload File',
|
||||||
|
'common.dropFile': 'Drop your OpenAPI file here',
|
||||||
|
'common.jsonOrYaml': 'JSON or YAML',
|
||||||
|
|
||||||
|
// ===== Theme =====
|
||||||
|
'theme.light': 'Light',
|
||||||
|
'theme.dark': 'Dark',
|
||||||
|
'theme.system': 'System',
|
||||||
|
|
||||||
|
// ===== Auth =====
|
||||||
|
// Login
|
||||||
|
'auth.login.title': 'Sign in to AgentFox',
|
||||||
|
'auth.login.subtitle': 'API documentation for LLMs',
|
||||||
|
'auth.login.email': 'Email',
|
||||||
|
'auth.login.password': 'Password',
|
||||||
|
'auth.login.submit': 'Sign In',
|
||||||
|
'auth.login.submitting': 'Signing in...',
|
||||||
|
'auth.login.noAccount': 'Don\'t have an account?',
|
||||||
|
'auth.login.signUp': 'Sign Up',
|
||||||
|
'auth.login.emailRequired': 'Email is required',
|
||||||
|
'auth.login.emailInvalid': 'Please enter a valid email address',
|
||||||
|
'auth.login.passwordRequired': 'Password is required',
|
||||||
|
'auth.login.passwordPlaceholder': 'Enter your password',
|
||||||
|
'auth.login.or': 'or continue with',
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
'auth.productName': 'AgentFox',
|
||||||
|
'auth.slogan': 'API Docs for LLMs, Done Right',
|
||||||
|
'auth.feature1': 'Multi-level API retrieval for minimal token usage',
|
||||||
|
'auth.feature2': 'Import OpenAPI specs in seconds',
|
||||||
|
'auth.feature3': 'Works with any MCP-compatible LLM',
|
||||||
|
|
||||||
|
// Register
|
||||||
|
'auth.register.title': 'Create your account',
|
||||||
|
'auth.register.subtitle': 'Get started with AgentFox',
|
||||||
|
'auth.register.name': 'Name',
|
||||||
|
'auth.register.email': 'Email',
|
||||||
|
'auth.register.password': 'Password',
|
||||||
|
'auth.register.submit': 'Create Account',
|
||||||
|
'auth.register.submitting': 'Creating account...',
|
||||||
|
'auth.register.hasAccount': 'Already have an account?',
|
||||||
|
'auth.register.signIn': 'Sign In',
|
||||||
|
'auth.register.nameRequired': 'Name is required',
|
||||||
|
'auth.register.emailRequired': 'Email is required',
|
||||||
|
'auth.register.emailInvalid': 'Please enter a valid email address',
|
||||||
|
'auth.register.passwordRequired': 'Password is required',
|
||||||
|
'auth.register.passwordMin': 'Password must be at least 8 characters',
|
||||||
|
'auth.register.namePlaceholder': 'Your name',
|
||||||
|
'auth.register.passwordPlaceholder': 'At least 8 characters',
|
||||||
|
'auth.register.or': 'or continue with',
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
'auth.oauth.google': 'Google',
|
||||||
|
'auth.oauth.github': 'GitHub',
|
||||||
|
'auth.oauth.apple': 'Apple',
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
'auth.callback.loading': 'Completing sign in...',
|
||||||
|
'auth.callback.error': 'Sign in failed',
|
||||||
|
'auth.callback.retry': 'Try again',
|
||||||
|
|
||||||
|
// ===== Dashboard Layout =====
|
||||||
|
'dashboard.layout.projects': 'Projects',
|
||||||
|
'dashboard.layout.allProjects': 'All Projects',
|
||||||
|
'dashboard.layout.onboardingTitle': 'Welcome! Generate an API key to start using MCP services.',
|
||||||
|
'dashboard.layout.onboardingDesc': 'You\'ll need an API key to connect your LLM client to your projects.',
|
||||||
|
'dashboard.layout.generateApiKey': 'Generate API Key',
|
||||||
|
|
||||||
|
// ===== Dashboard Projects =====
|
||||||
|
'dashboard.projects.title': 'Projects',
|
||||||
|
'dashboard.projects.importBtn': 'Import API Doc',
|
||||||
|
'dashboard.projects.emptyTitle': 'No projects yet',
|
||||||
|
'dashboard.projects.emptyDesc': 'Import an OpenAPI document to get started with MCP-powered API documentation.',
|
||||||
|
'dashboard.projects.importFirst': 'Import Your First API',
|
||||||
|
'dashboard.projects.deleteTitle': 'Delete project',
|
||||||
|
'dashboard.projects.deleteDesc': 'Are you sure you want to delete "{name}"? This will permanently remove all modules, endpoints, and MCP configuration.',
|
||||||
|
'dashboard.projects.deleteBtn': 'Delete project',
|
||||||
|
|
||||||
|
// ===== Project Detail =====
|
||||||
|
'dashboard.projectDetail.breadcrumbProjects': 'Projects',
|
||||||
|
'dashboard.projectDetail.notFound': 'Project not found',
|
||||||
|
'dashboard.projectDetail.backToProjects': 'Back to projects',
|
||||||
|
'dashboard.projectDetail.tabMcp': 'MCP',
|
||||||
|
'dashboard.projectDetail.tabDocs': 'Documentation',
|
||||||
|
'dashboard.projectDetail.tabModules': 'Modules',
|
||||||
|
'dashboard.projectDetail.tabSettings': 'Settings',
|
||||||
|
|
||||||
|
// ===== Import Dialog =====
|
||||||
|
'dashboard.import.title': 'Import OpenAPI Document',
|
||||||
|
'dashboard.import.desc': 'Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.',
|
||||||
|
'dashboard.import.successTitle': 'Import Successful',
|
||||||
|
'dashboard.import.goToProject': 'Go to Project',
|
||||||
|
|
||||||
|
// ===== Reimport Dialog =====
|
||||||
|
'dashboard.reimport.title': 'Re-import API Document',
|
||||||
|
'dashboard.reimport.subtitle': 'This action will replace all existing data.',
|
||||||
|
'dashboard.reimport.warningTitle': 'The following data will be permanently deleted:',
|
||||||
|
'dashboard.reimport.warningModules': '{count} module(s)',
|
||||||
|
'dashboard.reimport.warningEndpoints': '{count} endpoint(s)',
|
||||||
|
'dashboard.reimport.warningNote': 'New modules and endpoints will be created from the imported document. The API key will remain unchanged.',
|
||||||
|
'dashboard.reimport.importTitle': 'Import New Document',
|
||||||
|
'dashboard.reimport.importDesc': 'Provide a Swagger 2.0 or OpenAPI 3.x document.',
|
||||||
|
'dashboard.reimport.submit': 'Re-import',
|
||||||
|
'dashboard.reimport.successTitle': 'Re-import Successful',
|
||||||
|
'dashboard.reimport.successDesc': 'API documentation has been updated.',
|
||||||
|
|
||||||
|
// ===== MCP Integration =====
|
||||||
|
'dashboard.mcp.urlTitle': 'MCP Service URL',
|
||||||
|
'dashboard.mcp.urlDesc': 'Connect your LLM client to this endpoint.',
|
||||||
|
'dashboard.mcp.configTitle': 'Configuration for Claude Code / Cursor',
|
||||||
|
'dashboard.mcp.configDesc': 'Add this to your MCP client configuration.',
|
||||||
|
'dashboard.mcp.keyGenerated': 'API key generated. Copy it from',
|
||||||
|
'dashboard.mcp.keyReplace': 'and replace',
|
||||||
|
'dashboard.mcp.keyAbove': 'above.',
|
||||||
|
'dashboard.mcp.noKeyWarning': 'You need to generate an API key before using MCP.',
|
||||||
|
'dashboard.mcp.openSettings': 'Open Settings',
|
||||||
|
'dashboard.mcp.toolsTitle': 'Available MCP Tools',
|
||||||
|
'dashboard.mcp.toolsDesc': '5 tools for progressive drill-down, designed for minimal token usage.',
|
||||||
|
'dashboard.mcp.tool1Desc': 'Get project name, version, base URL, and module summary. Call this first.',
|
||||||
|
'dashboard.mcp.tool2Desc': 'List all modules with descriptions and endpoint counts.',
|
||||||
|
'dashboard.mcp.tool3Desc': 'List endpoints in a module. Provide moduleId.',
|
||||||
|
'dashboard.mcp.tool4Desc': 'Get full endpoint details: parameters, request body, responses.',
|
||||||
|
'dashboard.mcp.tool5Desc': 'Search by keyword across all endpoints. Optional moduleId filter.',
|
||||||
|
|
||||||
|
// ===== Project Settings =====
|
||||||
|
'dashboard.projectSettings.generalTitle': 'General',
|
||||||
|
'dashboard.projectSettings.generalDesc': 'Update your project name and description.',
|
||||||
|
'dashboard.projectSettings.projectName': 'Project Name',
|
||||||
|
'dashboard.projectSettings.description': 'Description',
|
||||||
|
'dashboard.projectSettings.saveChanges': 'Save Changes',
|
||||||
|
'dashboard.projectSettings.saved': 'Saved',
|
||||||
|
'dashboard.projectSettings.reimportTitle': 'Re-import API Document',
|
||||||
|
'dashboard.projectSettings.reimportDesc': 'Replace the current API documentation with a new OpenAPI document. This will clear all existing modules ({modules}) and endpoints ({endpoints}), then recreate them from the new document.',
|
||||||
|
'dashboard.projectSettings.reimportBtn': 'Re-import Document',
|
||||||
|
'dashboard.projectSettings.dangerZone': 'Danger Zone',
|
||||||
|
'dashboard.projectSettings.dangerDesc': 'Permanently delete this project and all its data. This action cannot be undone.',
|
||||||
|
'dashboard.projectSettings.deleteProject': 'Delete Project',
|
||||||
|
'dashboard.projectSettings.deleteTitle': 'Delete project',
|
||||||
|
'dashboard.projectSettings.deleteDesc': 'Permanently delete "{name}"? All modules, endpoints, and MCP configuration will be removed.',
|
||||||
|
|
||||||
|
// ===== Module Management =====
|
||||||
|
'dashboard.modules.addTitle': 'Add Manual Module',
|
||||||
|
'dashboard.modules.placeholder': 'Module name',
|
||||||
|
'dashboard.modules.allModules': 'All Modules',
|
||||||
|
'dashboard.modules.emptyTitle': 'No modules yet',
|
||||||
|
'dashboard.modules.emptyDesc': 'Modules are automatically created when you import an API document. You can also add manual modules above.',
|
||||||
|
'dashboard.modules.deleteTitle': 'Delete module',
|
||||||
|
'dashboard.modules.deleteDesc': 'Delete "{name}"? This will also remove its {count} endpoints.',
|
||||||
|
'dashboard.modules.deleteBtn': 'Delete module',
|
||||||
|
|
||||||
|
// ===== Doc Preview =====
|
||||||
|
'dashboard.docs.modules': 'Modules',
|
||||||
|
'dashboard.docs.noModules': 'No modules',
|
||||||
|
'dashboard.docs.allEndpoints': 'All endpoints',
|
||||||
|
'dashboard.docs.noEndpoints': 'No endpoints',
|
||||||
|
'dashboard.docs.noEndpointsModule': 'This module has no endpoints.',
|
||||||
|
'dashboard.docs.noEndpointsProject': 'No endpoints in this project yet. Import an API document to get started.',
|
||||||
|
'dashboard.docs.deprecated': 'deprecated',
|
||||||
|
'dashboard.docs.operationId': 'Operation ID',
|
||||||
|
|
||||||
|
// ===== Schema View =====
|
||||||
|
'dashboard.schema.parameters': 'Parameters',
|
||||||
|
'dashboard.schema.name': 'Name',
|
||||||
|
'dashboard.schema.in': 'In',
|
||||||
|
'dashboard.schema.type': 'Type',
|
||||||
|
'dashboard.schema.required': 'required',
|
||||||
|
'dashboard.schema.optional': 'optional',
|
||||||
|
'dashboard.schema.descriptionCol': 'Description',
|
||||||
|
'dashboard.schema.requestBody': 'Request Body',
|
||||||
|
'dashboard.schema.responses': 'Responses',
|
||||||
|
'dashboard.schema.noSchema': 'No schema',
|
||||||
|
'dashboard.schema.ofObjects': 'of objects:',
|
||||||
|
'dashboard.schema.enum': 'enum:',
|
||||||
|
'dashboard.schema.default': 'default:',
|
||||||
|
'dashboard.schema.nullable': 'nullable',
|
||||||
|
|
||||||
|
// ===== Settings Dialog =====
|
||||||
|
'dashboard.settings.title': 'Settings',
|
||||||
|
'dashboard.settings.profileTitle': 'Profile',
|
||||||
|
'dashboard.settings.profileDesc': 'Manage your personal information.',
|
||||||
|
'dashboard.settings.displayName': 'Display Name',
|
||||||
|
'dashboard.settings.saveProfile': 'Save Profile',
|
||||||
|
'dashboard.settings.saving': 'Saving...',
|
||||||
|
'dashboard.settings.profileUpdated': 'Profile updated',
|
||||||
|
'dashboard.settings.apiKeyTitle': 'API Key',
|
||||||
|
'dashboard.settings.apiKeyDesc': 'Used to authenticate all MCP requests across your projects.',
|
||||||
|
'dashboard.settings.keySaveWarning': 'Save this key now — you won\'t be able to see it again.',
|
||||||
|
'dashboard.settings.copyToClipboard': 'Copy to Clipboard',
|
||||||
|
'dashboard.settings.keySaved': 'I\'ve saved it, continue',
|
||||||
|
'dashboard.settings.noKey': 'No API key generated yet. Generate one to use MCP services.',
|
||||||
|
'dashboard.settings.generateKey': 'Generate API Key',
|
||||||
|
'dashboard.settings.generating': 'Generating...',
|
||||||
|
'dashboard.settings.rotateKey': 'Rotate API Key',
|
||||||
|
'dashboard.settings.rotateTitle': 'Rotate API Key',
|
||||||
|
'dashboard.settings.rotateDesc': 'The current API key will be invalidated immediately. All MCP clients using the old key will stop working. A new key will be generated.',
|
||||||
|
'dashboard.settings.rotateConfirm': 'Rotate Key',
|
||||||
|
'dashboard.settings.passwordPrompt': 'Enter your password to {action} the API key.',
|
||||||
|
'dashboard.settings.passwordPromptCopy': 'copy',
|
||||||
|
'dashboard.settings.passwordPromptReveal': 'reveal',
|
||||||
|
'dashboard.settings.currentPassword': 'Current password',
|
||||||
|
'dashboard.settings.verifying': 'Verifying...',
|
||||||
|
'dashboard.settings.changePasswordTitle': 'Change Password',
|
||||||
|
'dashboard.settings.changePasswordDesc': 'Update your password to keep your account secure.',
|
||||||
|
'dashboard.settings.currentPasswordLabel': 'Current Password',
|
||||||
|
'dashboard.settings.newPasswordLabel': 'New Password',
|
||||||
|
'dashboard.settings.confirmPasswordLabel': 'Confirm New Password',
|
||||||
|
'dashboard.settings.changePassword': 'Change Password',
|
||||||
|
'dashboard.settings.changingPassword': 'Changing...',
|
||||||
|
'dashboard.settings.passwordMismatch': 'Passwords do not match',
|
||||||
|
'dashboard.settings.passwordChanged': 'Password changed successfully',
|
||||||
|
'dashboard.settings.enterCurrentPassword': 'Enter current password',
|
||||||
|
'dashboard.settings.atLeast8Chars': 'At least 8 characters',
|
||||||
|
'dashboard.settings.confirmNewPassword': 'Confirm new password',
|
||||||
|
'dashboard.settings.setPasswordTitle': 'Set Password',
|
||||||
|
'dashboard.settings.setPasswordDesc': 'You signed in with a third-party account. Set a password to reveal or copy your API key.',
|
||||||
|
'dashboard.settings.setPassword': 'Set Password',
|
||||||
|
'dashboard.settings.settingPassword': 'Setting...',
|
||||||
|
'dashboard.settings.passwordSet': 'Password set successfully',
|
||||||
|
'dashboard.settings.setPasswordToReveal': 'Set a password first to reveal your API key.',
|
||||||
|
'dashboard.settings.setPasswordAction': 'Set Password',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default en;
|
||||||
389
packages/web/src/lib/i18n/zh.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import type { TranslationKey } from '../i18n';
|
||||||
|
|
||||||
|
const zh: Record<TranslationKey, string> = {
|
||||||
|
// ===== Landing Page =====
|
||||||
|
|
||||||
|
// Nav
|
||||||
|
'nav.features': '特性',
|
||||||
|
'nav.tools': '工具',
|
||||||
|
'nav.testimonials': '用户评价',
|
||||||
|
'nav.pricing': '定价',
|
||||||
|
'nav.faq': '常见问题',
|
||||||
|
'nav.signIn': '登录',
|
||||||
|
'nav.getStarted': '免费开始',
|
||||||
|
'nav.dashboard': '控制台',
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
'hero.badge': 'MCP 驱动的 API 智能服务',
|
||||||
|
'hero.title': '为 LLM 而生的',
|
||||||
|
'hero.titleHighlight': 'API 文档',
|
||||||
|
'hero.subtitle': '让 AI 代理以精准的方式查询你的 OpenAPI 文档。多级检索只提供所需的 token,而非整个规范。',
|
||||||
|
'hero.cta': '免费开始',
|
||||||
|
'hero.ctaSecondary': '查看文档',
|
||||||
|
'hero.terminal.comment': '# 将你的 AI 工具连接到任何 API 文档',
|
||||||
|
'hero.terminal.cmd1': 'get_project_overview',
|
||||||
|
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
|
||||||
|
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
|
||||||
|
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
|
||||||
|
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
|
||||||
|
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
|
||||||
|
|
||||||
|
// Features
|
||||||
|
'features.label': '核心特性',
|
||||||
|
'features.title': '智能 API 检索',
|
||||||
|
'features.subtitle': '五个专用 MCP 工具,每次调用最小化 token 消耗',
|
||||||
|
'features.progressive.title': '渐进式下钻',
|
||||||
|
'features.progressive.desc': '从项目概览到模块列表再到端点详情,按需精确检索。',
|
||||||
|
'features.token.title': 'Token 高效',
|
||||||
|
'features.token.desc': '每次调用 ~200-2,000 tokens,对比全量 OpenAPI 规范的 10,000+ tokens。',
|
||||||
|
'features.spec.title': '全规范支持',
|
||||||
|
'features.spec.desc': '导入 OpenAPI 3.x 和 Swagger 2.0 文档,所有 $ref 自动解引用。',
|
||||||
|
'features.import.title': '一键导入',
|
||||||
|
'features.import.desc': '粘贴 URL 或上传 JSON/YAML 文件,API 文档即时解析并索引。',
|
||||||
|
'features.projects.title': '多项目管理',
|
||||||
|
'features.projects.desc': '将 API 组织到独立项目中,每个项目拥有专属 MCP 端点和 API Key。',
|
||||||
|
'features.security.title': '安全可靠',
|
||||||
|
'features.security.desc': '项目级 API Key(bcrypt 哈希加密),JWT 双令牌认证,零共享密钥。',
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
'tools.label': '兼容性',
|
||||||
|
'tools.title': '兼容你常用的 AI 工具',
|
||||||
|
'tools.subtitle': 'AgentFox 使用 MCP 协议 — 主流 AI 编程助手均已支持的通用协议',
|
||||||
|
'tools.claude.name': 'Claude Code',
|
||||||
|
'tools.claude.desc': 'Anthropic CLI',
|
||||||
|
'tools.codex.name': 'Codex',
|
||||||
|
'tools.codex.desc': 'OpenAI CLI',
|
||||||
|
'tools.cursor.name': 'Cursor',
|
||||||
|
'tools.cursor.desc': 'AI 代码编辑器',
|
||||||
|
'tools.copilot.name': 'GitHub Copilot',
|
||||||
|
'tools.copilot.desc': 'GitHub AI 助手',
|
||||||
|
'tools.gemini.name': 'Gemini CLI',
|
||||||
|
'tools.gemini.desc': 'Google AI CLI',
|
||||||
|
'tools.antigravity.name': 'Antigravity',
|
||||||
|
'tools.antigravity.desc': 'AI 开发平台',
|
||||||
|
'tools.openclaw.name': 'OpenClaw',
|
||||||
|
'tools.openclaw.desc': 'AI 开发平台',
|
||||||
|
|
||||||
|
// Testimonials
|
||||||
|
'testimonials.label': '用户评价',
|
||||||
|
'testimonials.title': '深受开发者喜爱',
|
||||||
|
'testimonials.1.quote': 'AgentFox 将我们的 API 集成时间缩短了一半。Claude 通过 MCP 直接查询所需内容,不再需要复制粘贴文档。',
|
||||||
|
'testimonials.1.name': 'Sarah Chen',
|
||||||
|
'testimonials.1.role': 'Vercel 高级工程师',
|
||||||
|
'testimonials.2.quote': 'Token 节省是实实在在的 — 我们 Cursor 工作流从每次 API 调用消耗 15K tokens 降到了不到 1K。复杂集成的真正利器。',
|
||||||
|
'testimonials.2.name': 'Marcus Rivera',
|
||||||
|
'testimonials.2.role': 'Stackblitz CTO',
|
||||||
|
'testimonials.3.quote': '我们在一周内将 50+ 内部 API 接入了 AgentFox。现在每个团队的 AI 助手都能发现和使用任何服务端点。',
|
||||||
|
'testimonials.3.name': 'Yuki Tanaka',
|
||||||
|
'testimonials.3.role': 'Shopify 平台负责人',
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
'pricing.label': '定价方案',
|
||||||
|
'pricing.title': '简洁透明的定价',
|
||||||
|
'pricing.subtitle': '免费起步,按需扩展',
|
||||||
|
'pricing.free.name': '免费版',
|
||||||
|
'pricing.free.price': '¥0',
|
||||||
|
'pricing.free.period': '/月',
|
||||||
|
'pricing.free.desc': '体验 MCP 驱动的 API 文档服务',
|
||||||
|
'pricing.free.f1': '1 个项目',
|
||||||
|
'pricing.free.f2': '每日 100 次 MCP 查询',
|
||||||
|
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
|
||||||
|
'pricing.free.f4': '社区支持',
|
||||||
|
'pricing.free.cta': '免费开始',
|
||||||
|
'pricing.pro.name': '专业版',
|
||||||
|
'pricing.pro.price': '¥199',
|
||||||
|
'pricing.pro.period': '/月',
|
||||||
|
'pricing.pro.badge': '最受欢迎',
|
||||||
|
'pricing.pro.desc': '为 AI 辅助开发团队打造',
|
||||||
|
'pricing.pro.f1': '无限项目',
|
||||||
|
'pricing.pro.f2': '无限 MCP 查询',
|
||||||
|
'pricing.pro.f3': '优先导入队列',
|
||||||
|
'pricing.pro.f4': '团队协作',
|
||||||
|
'pricing.pro.f5': '优先支持',
|
||||||
|
'pricing.pro.cta': '开始免费试用',
|
||||||
|
'pricing.enterprise.name': '企业版',
|
||||||
|
'pricing.enterprise.price': '联系我们',
|
||||||
|
'pricing.enterprise.period': '',
|
||||||
|
'pricing.enterprise.desc': '满足企业级高级需求',
|
||||||
|
'pricing.enterprise.f1': '私有化部署',
|
||||||
|
'pricing.enterprise.f2': 'SSO / SAML',
|
||||||
|
'pricing.enterprise.f3': 'SLA 保障',
|
||||||
|
'pricing.enterprise.f4': '专属支持',
|
||||||
|
'pricing.enterprise.f5': '定制集成',
|
||||||
|
'pricing.enterprise.cta': '联系销售',
|
||||||
|
|
||||||
|
// FAQ
|
||||||
|
'faq.label': '常见问题',
|
||||||
|
'faq.title': '常见问题解答',
|
||||||
|
'faq.1.q': '什么是 MCP?AgentFox 如何使用它?',
|
||||||
|
'faq.1.a': 'MCP(Model Context Protocol)是一个开放标准,让 AI 助手能够连接外部工具和数据源。AgentFox 通过 MCP 工具暴露你的 API 文档,让 Claude Code、Cursor、Copilot 等 AI 编程助手可以按需查询端点详情,而无需将整个规范放入上下文窗口。',
|
||||||
|
'faq.2.q': '支持哪些 OpenAPI 格式?',
|
||||||
|
'faq.2.a': 'AgentFox 支持 OpenAPI 3.x 和 Swagger 2.0 规范。你可以导入 JSON 或 YAML 格式的文档,也可以提供 URL 直接获取。导入时所有 $ref 引用会自动解引用。',
|
||||||
|
'faq.3.q': '能减少多少 Token 消耗?',
|
||||||
|
'faq.3.a': '每次 MCP 工具调用返回 ~200-2,000 tokens 的精准信息,相比全量 API 规范的 10,000+ tokens。对于典型的集成任务,这意味着 80-95% 的 token 消耗降低。',
|
||||||
|
'faq.4.q': '我的 API 文档安全吗?',
|
||||||
|
'faq.4.a': '是的。每个项目拥有独立的 API Key(bcrypt 哈希加密,从不以明文存储)。MCP 端点每次请求都需要认证。用户仪表盘使用 JWT 并自动轮换 token。',
|
||||||
|
'faq.5.q': '兼容哪些 AI 工具?',
|
||||||
|
'faq.5.a': '任何支持 MCP 协议的工具都可以连接 AgentFox,包括 Claude Code、OpenAI Codex CLI、OpenClaw、Gemini CLI、Cursor、GitHub Copilot(通过 MCP 插件)、Antigravity 等。如果你的工具支持 MCP,就能与 AgentFox 配合使用。',
|
||||||
|
'faq.6.q': '可以私有化部署吗?',
|
||||||
|
'faq.6.a': '可以!AgentFox 支持云端和私有化部署。企业版包含完整的 Docker Compose 私有化部署支持,以及 SSO 集成和专属技术支持。',
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
'footer.product': '产品',
|
||||||
|
'footer.features': '特性',
|
||||||
|
'footer.pricing': '定价',
|
||||||
|
'footer.docs': '文档',
|
||||||
|
'footer.changelog': '更新日志',
|
||||||
|
'footer.resources': '资源',
|
||||||
|
'footer.github': 'GitHub',
|
||||||
|
'footer.community': '社区',
|
||||||
|
'footer.blog': '博客',
|
||||||
|
'footer.legal': '法律',
|
||||||
|
'footer.privacy': '隐私政策',
|
||||||
|
'footer.terms': '服务条款',
|
||||||
|
'footer.copyright': '© 2026 AgentFox. 保留所有权利。',
|
||||||
|
'footer.tagline': '为 AI 代理打造的 MCP 驱动 API 文档服务。',
|
||||||
|
|
||||||
|
// ===== Common =====
|
||||||
|
'common.cancel': '取消',
|
||||||
|
'common.confirm': '确认',
|
||||||
|
'common.delete': '删除',
|
||||||
|
'common.save': '保存',
|
||||||
|
'common.back': '返回',
|
||||||
|
'common.done': '完成',
|
||||||
|
'common.copy': '复制',
|
||||||
|
'common.copied': '已复制',
|
||||||
|
'common.continue': '继续',
|
||||||
|
'common.import': '导入',
|
||||||
|
'common.importing': '导入中...',
|
||||||
|
'common.signOut': '退出登录',
|
||||||
|
'common.signOutConfirm': '确定要退出登录吗?',
|
||||||
|
'common.settings': '设置',
|
||||||
|
'common.modules': '模块',
|
||||||
|
'common.endpoints': '端点',
|
||||||
|
'common.total': '总计',
|
||||||
|
'common.add': '添加',
|
||||||
|
'common.fromUrl': '从 URL',
|
||||||
|
'common.uploadFile': '上传文件',
|
||||||
|
'common.dropFile': '将 OpenAPI 文件拖放到这里',
|
||||||
|
'common.jsonOrYaml': 'JSON 或 YAML',
|
||||||
|
|
||||||
|
// ===== Theme =====
|
||||||
|
'theme.light': '浅色',
|
||||||
|
'theme.dark': '深色',
|
||||||
|
'theme.system': '跟随系统',
|
||||||
|
|
||||||
|
// ===== Auth =====
|
||||||
|
// Login
|
||||||
|
'auth.login.title': '登录 AgentFox',
|
||||||
|
'auth.login.subtitle': '为 LLM 打造的 API 文档服务',
|
||||||
|
'auth.login.email': '邮箱',
|
||||||
|
'auth.login.password': '密码',
|
||||||
|
'auth.login.submit': '登录',
|
||||||
|
'auth.login.submitting': '登录中...',
|
||||||
|
'auth.login.noAccount': '还没有账号?',
|
||||||
|
'auth.login.signUp': '注册',
|
||||||
|
'auth.login.emailRequired': '请输入邮箱',
|
||||||
|
'auth.login.emailInvalid': '请输入有效的邮箱地址',
|
||||||
|
'auth.login.passwordRequired': '请输入密码',
|
||||||
|
'auth.login.passwordPlaceholder': '输入你的密码',
|
||||||
|
'auth.login.or': '或者通过以下方式继续',
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
'auth.productName': 'AgentFox',
|
||||||
|
'auth.slogan': 'LLM 专属 API 文档方案',
|
||||||
|
'auth.feature1': '多级 API 检索,最小化 Token 消耗',
|
||||||
|
'auth.feature2': '秒级导入 OpenAPI 文档',
|
||||||
|
'auth.feature3': '兼容所有 MCP 协议的 LLM 工具',
|
||||||
|
|
||||||
|
// Register
|
||||||
|
'auth.register.title': '创建账号',
|
||||||
|
'auth.register.subtitle': '开始使用 AgentFox',
|
||||||
|
'auth.register.name': '姓名',
|
||||||
|
'auth.register.email': '邮箱',
|
||||||
|
'auth.register.password': '密码',
|
||||||
|
'auth.register.submit': '创建账号',
|
||||||
|
'auth.register.submitting': '创建中...',
|
||||||
|
'auth.register.hasAccount': '已有账号?',
|
||||||
|
'auth.register.signIn': '登录',
|
||||||
|
'auth.register.nameRequired': '请输入姓名',
|
||||||
|
'auth.register.emailRequired': '请输入邮箱',
|
||||||
|
'auth.register.emailInvalid': '请输入有效的邮箱地址',
|
||||||
|
'auth.register.passwordRequired': '请输入密码',
|
||||||
|
'auth.register.passwordMin': '密码至少需要 8 个字符',
|
||||||
|
'auth.register.namePlaceholder': '你的姓名',
|
||||||
|
'auth.register.passwordPlaceholder': '至少 8 个字符',
|
||||||
|
'auth.register.or': '或者通过以下方式继续',
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
'auth.oauth.google': 'Google',
|
||||||
|
'auth.oauth.github': 'GitHub',
|
||||||
|
'auth.oauth.apple': 'Apple',
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
'auth.callback.loading': '正在完成登录...',
|
||||||
|
'auth.callback.error': '登录失败',
|
||||||
|
'auth.callback.retry': '重试',
|
||||||
|
|
||||||
|
// ===== Dashboard Layout =====
|
||||||
|
'dashboard.layout.projects': '项目',
|
||||||
|
'dashboard.layout.allProjects': '所有项目',
|
||||||
|
'dashboard.layout.onboardingTitle': '欢迎!生成 API Key 以开始使用 MCP 服务。',
|
||||||
|
'dashboard.layout.onboardingDesc': '你需要一个 API Key 来将 LLM 客户端连接到你的项目。',
|
||||||
|
'dashboard.layout.generateApiKey': '生成 API Key',
|
||||||
|
|
||||||
|
// ===== Dashboard Projects =====
|
||||||
|
'dashboard.projects.title': '项目',
|
||||||
|
'dashboard.projects.importBtn': '导入 API 文档',
|
||||||
|
'dashboard.projects.emptyTitle': '暂无项目',
|
||||||
|
'dashboard.projects.emptyDesc': '导入 OpenAPI 文档以开始使用 MCP 驱动的 API 文档服务。',
|
||||||
|
'dashboard.projects.importFirst': '导入你的第一个 API',
|
||||||
|
'dashboard.projects.deleteTitle': '删除项目',
|
||||||
|
'dashboard.projects.deleteDesc': '确定要删除"{name}"吗?这将永久删除所有模块、端点和 MCP 配置。',
|
||||||
|
'dashboard.projects.deleteBtn': '删除项目',
|
||||||
|
|
||||||
|
// ===== Project Detail =====
|
||||||
|
'dashboard.projectDetail.breadcrumbProjects': '项目',
|
||||||
|
'dashboard.projectDetail.notFound': '项目未找到',
|
||||||
|
'dashboard.projectDetail.backToProjects': '返回项目列表',
|
||||||
|
'dashboard.projectDetail.tabMcp': 'MCP',
|
||||||
|
'dashboard.projectDetail.tabDocs': '文档',
|
||||||
|
'dashboard.projectDetail.tabModules': '模块',
|
||||||
|
'dashboard.projectDetail.tabSettings': '设置',
|
||||||
|
|
||||||
|
// ===== Import Dialog =====
|
||||||
|
'dashboard.import.title': '导入 OpenAPI 文档',
|
||||||
|
'dashboard.import.desc': '导入 Swagger 2.0 或 OpenAPI 3.x 文档以创建新项目。',
|
||||||
|
'dashboard.import.successTitle': '导入成功',
|
||||||
|
'dashboard.import.goToProject': '前往项目',
|
||||||
|
|
||||||
|
// ===== Reimport Dialog =====
|
||||||
|
'dashboard.reimport.title': '重新导入 API 文档',
|
||||||
|
'dashboard.reimport.subtitle': '此操作将替换所有现有数据。',
|
||||||
|
'dashboard.reimport.warningTitle': '以下数据将被永久删除:',
|
||||||
|
'dashboard.reimport.warningModules': '{count} 个模块',
|
||||||
|
'dashboard.reimport.warningEndpoints': '{count} 个端点',
|
||||||
|
'dashboard.reimport.warningNote': '将根据导入的文档创建新的模块和端点。API Key 将保持不变。',
|
||||||
|
'dashboard.reimport.importTitle': '导入新文档',
|
||||||
|
'dashboard.reimport.importDesc': '提供 Swagger 2.0 或 OpenAPI 3.x 文档。',
|
||||||
|
'dashboard.reimport.submit': '重新导入',
|
||||||
|
'dashboard.reimport.successTitle': '重新导入成功',
|
||||||
|
'dashboard.reimport.successDesc': 'API 文档已更新。',
|
||||||
|
|
||||||
|
// ===== MCP Integration =====
|
||||||
|
'dashboard.mcp.urlTitle': 'MCP 服务 URL',
|
||||||
|
'dashboard.mcp.urlDesc': '将你的 LLM 客户端连接到此端点。',
|
||||||
|
'dashboard.mcp.configTitle': 'Claude Code / Cursor 配置',
|
||||||
|
'dashboard.mcp.configDesc': '将此内容添加到你的 MCP 客户端配置中。',
|
||||||
|
'dashboard.mcp.keyGenerated': 'API Key 已生成。从',
|
||||||
|
'dashboard.mcp.keyReplace': '复制并替换上方的',
|
||||||
|
'dashboard.mcp.keyAbove': '。',
|
||||||
|
'dashboard.mcp.noKeyWarning': '使用 MCP 前需要先生成 API Key。',
|
||||||
|
'dashboard.mcp.openSettings': '打开设置',
|
||||||
|
'dashboard.mcp.toolsTitle': '可用 MCP 工具',
|
||||||
|
'dashboard.mcp.toolsDesc': '5 个渐进式下钻工具,为最小 token 消耗而设计。',
|
||||||
|
'dashboard.mcp.tool1Desc': '获取项目名称、版本、基础 URL 和模块摘要。首先调用此工具。',
|
||||||
|
'dashboard.mcp.tool2Desc': '列出所有模块及其描述和端点数量。',
|
||||||
|
'dashboard.mcp.tool3Desc': '列出模块中的端点。需提供 moduleId。',
|
||||||
|
'dashboard.mcp.tool4Desc': '获取完整端点详情:参数、请求体、响应。',
|
||||||
|
'dashboard.mcp.tool5Desc': '按关键词搜索所有端点。可选 moduleId 过滤。',
|
||||||
|
|
||||||
|
// ===== Project Settings =====
|
||||||
|
'dashboard.projectSettings.generalTitle': '基本信息',
|
||||||
|
'dashboard.projectSettings.generalDesc': '更新项目名称和描述。',
|
||||||
|
'dashboard.projectSettings.projectName': '项目名称',
|
||||||
|
'dashboard.projectSettings.description': '描述',
|
||||||
|
'dashboard.projectSettings.saveChanges': '保存更改',
|
||||||
|
'dashboard.projectSettings.saved': '已保存',
|
||||||
|
'dashboard.projectSettings.reimportTitle': '重新导入 API 文档',
|
||||||
|
'dashboard.projectSettings.reimportDesc': '使用新的 OpenAPI 文档替换当前 API 文档。这将清除所有现有模块({modules})和端点({endpoints}),然后根据新文档重新创建。',
|
||||||
|
'dashboard.projectSettings.reimportBtn': '重新导入文档',
|
||||||
|
'dashboard.projectSettings.dangerZone': '危险区域',
|
||||||
|
'dashboard.projectSettings.dangerDesc': '永久删除此项目及其所有数据。此操作不可撤销。',
|
||||||
|
'dashboard.projectSettings.deleteProject': '删除项目',
|
||||||
|
'dashboard.projectSettings.deleteTitle': '删除项目',
|
||||||
|
'dashboard.projectSettings.deleteDesc': '永久删除"{name}"?所有模块、端点和 MCP 配置将被移除。',
|
||||||
|
|
||||||
|
// ===== Module Management =====
|
||||||
|
'dashboard.modules.addTitle': '添加手动模块',
|
||||||
|
'dashboard.modules.placeholder': '模块名称',
|
||||||
|
'dashboard.modules.allModules': '所有模块',
|
||||||
|
'dashboard.modules.emptyTitle': '暂无模块',
|
||||||
|
'dashboard.modules.emptyDesc': '导入 API 文档时会自动创建模块。你也可以在上方手动添加模块。',
|
||||||
|
'dashboard.modules.deleteTitle': '删除模块',
|
||||||
|
'dashboard.modules.deleteDesc': '删除"{name}"?这将同时删除其 {count} 个端点。',
|
||||||
|
'dashboard.modules.deleteBtn': '删除模块',
|
||||||
|
|
||||||
|
// ===== Doc Preview =====
|
||||||
|
'dashboard.docs.modules': '模块',
|
||||||
|
'dashboard.docs.noModules': '暂无模块',
|
||||||
|
'dashboard.docs.allEndpoints': '所有端点',
|
||||||
|
'dashboard.docs.noEndpoints': '暂无端点',
|
||||||
|
'dashboard.docs.noEndpointsModule': '此模块暂无端点。',
|
||||||
|
'dashboard.docs.noEndpointsProject': '此项目暂无端点。导入 API 文档以开始使用。',
|
||||||
|
'dashboard.docs.deprecated': '已弃用',
|
||||||
|
'dashboard.docs.operationId': '操作 ID',
|
||||||
|
|
||||||
|
// ===== Schema View =====
|
||||||
|
'dashboard.schema.parameters': '参数',
|
||||||
|
'dashboard.schema.name': '名称',
|
||||||
|
'dashboard.schema.in': '位置',
|
||||||
|
'dashboard.schema.type': '类型',
|
||||||
|
'dashboard.schema.required': '必填',
|
||||||
|
'dashboard.schema.optional': '可选',
|
||||||
|
'dashboard.schema.descriptionCol': '说明',
|
||||||
|
'dashboard.schema.requestBody': '请求体',
|
||||||
|
'dashboard.schema.responses': '响应',
|
||||||
|
'dashboard.schema.noSchema': '无 Schema',
|
||||||
|
'dashboard.schema.ofObjects': '对象数组:',
|
||||||
|
'dashboard.schema.enum': '枚举:',
|
||||||
|
'dashboard.schema.default': '默认值:',
|
||||||
|
'dashboard.schema.nullable': '可空',
|
||||||
|
|
||||||
|
// ===== Settings Dialog =====
|
||||||
|
'dashboard.settings.title': '设置',
|
||||||
|
'dashboard.settings.profileTitle': '个人资料',
|
||||||
|
'dashboard.settings.profileDesc': '管理你的个人信息。',
|
||||||
|
'dashboard.settings.displayName': '显示名称',
|
||||||
|
'dashboard.settings.saveProfile': '保存资料',
|
||||||
|
'dashboard.settings.saving': '保存中...',
|
||||||
|
'dashboard.settings.profileUpdated': '资料已更新',
|
||||||
|
'dashboard.settings.apiKeyTitle': 'API Key',
|
||||||
|
'dashboard.settings.apiKeyDesc': '用于验证所有项目的 MCP 请求。',
|
||||||
|
'dashboard.settings.keySaveWarning': '请立即保存此密钥 — 之后将无法再次查看。',
|
||||||
|
'dashboard.settings.copyToClipboard': '复制到剪贴板',
|
||||||
|
'dashboard.settings.keySaved': '我已保存,继续',
|
||||||
|
'dashboard.settings.noKey': '尚未生成 API Key。生成一个以使用 MCP 服务。',
|
||||||
|
'dashboard.settings.generateKey': '生成 API Key',
|
||||||
|
'dashboard.settings.generating': '生成中...',
|
||||||
|
'dashboard.settings.rotateKey': '轮换 API Key',
|
||||||
|
'dashboard.settings.rotateTitle': '轮换 API Key',
|
||||||
|
'dashboard.settings.rotateDesc': '当前 API Key 将立即失效。所有使用旧密钥的 MCP 客户端将停止工作。将生成新的密钥。',
|
||||||
|
'dashboard.settings.rotateConfirm': '轮换密钥',
|
||||||
|
'dashboard.settings.passwordPrompt': '输入密码以{action} API Key。',
|
||||||
|
'dashboard.settings.passwordPromptCopy': '复制',
|
||||||
|
'dashboard.settings.passwordPromptReveal': '查看',
|
||||||
|
'dashboard.settings.currentPassword': '当前密码',
|
||||||
|
'dashboard.settings.verifying': '验证中...',
|
||||||
|
'dashboard.settings.changePasswordTitle': '修改密码',
|
||||||
|
'dashboard.settings.changePasswordDesc': '更新密码以保护账号安全。',
|
||||||
|
'dashboard.settings.currentPasswordLabel': '当前密码',
|
||||||
|
'dashboard.settings.newPasswordLabel': '新密码',
|
||||||
|
'dashboard.settings.confirmPasswordLabel': '确认新密码',
|
||||||
|
'dashboard.settings.changePassword': '修改密码',
|
||||||
|
'dashboard.settings.changingPassword': '修改中...',
|
||||||
|
'dashboard.settings.passwordMismatch': '两次输入的密码不一致',
|
||||||
|
'dashboard.settings.passwordChanged': '密码修改成功',
|
||||||
|
'dashboard.settings.enterCurrentPassword': '输入当前密码',
|
||||||
|
'dashboard.settings.atLeast8Chars': '至少 8 个字符',
|
||||||
|
'dashboard.settings.confirmNewPassword': '确认新密码',
|
||||||
|
'dashboard.settings.setPasswordTitle': '设置密码',
|
||||||
|
'dashboard.settings.setPasswordDesc': '您通过第三方账号登录,设置密码后可以查看或复制 API Key。',
|
||||||
|
'dashboard.settings.setPassword': '设置密码',
|
||||||
|
'dashboard.settings.settingPassword': '设置中...',
|
||||||
|
'dashboard.settings.passwordSet': '密码设置成功',
|
||||||
|
'dashboard.settings.setPasswordToReveal': '请先设置密码才能查看 API Key。',
|
||||||
|
'dashboard.settings.setPasswordAction': '设置密码',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default zh;
|
||||||
64
packages/web/src/lib/theme.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
type ThemeContextType = {
|
||||||
|
theme: Theme;
|
||||||
|
resolved: 'light' | 'dark';
|
||||||
|
setTheme: (t: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||||
|
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||||
|
if (theme === 'system') {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
return (localStorage.getItem('agent-fox-theme') as Theme) || 'system';
|
||||||
|
});
|
||||||
|
const [resolved, setResolved] = useState<'light' | 'dark'>(() => applyTheme(
|
||||||
|
(localStorage.getItem('agent-fox-theme') as Theme) || 'system'
|
||||||
|
));
|
||||||
|
|
||||||
|
const setTheme = (t: Theme) => {
|
||||||
|
localStorage.setItem('agent-fox-theme', t);
|
||||||
|
setThemeState(t);
|
||||||
|
setResolved(applyTheme(t));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResolved(applyTheme(theme));
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== 'system') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = () => setResolved(applyTheme('system'));
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||