Compare commits

..

30 Commits

Author SHA1 Message Date
71c604411d feat: deploy skill 2026-04-12 20:15:41 +08:00
6fe04f4893 feat: 添加 Admin 管理后台
- 数据库新增 Role 枚举、disabled 字段和 McpCallLog 调用日志表
- 后端新增 requireAdmin 中间件和 /api/admin/* 管理接口(统计、用户、项目、日志)
- MCP 工具调用自动记录详细日志(耗时、参数、响应大小、客户端IP、token估算)
- 前端新增 /admin 路由区域:仪表盘、用户管理、项目管理、调用日志四个页面
- JWT 携带 role 字段,登录/OAuth 增加禁用账号检查
- nginx 配置补充 X-Forwarded-For 透传真实客户端 IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:04:44 +08:00
d45cc45815 docs: 更新 CLAUDE.md 补充部署、Docker、OAuth 等上下文
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:36:32 +08:00
f3fbd3876a refactor: OpenAPI URL 抓取改为前端执行 + 服务端 CORS 代理
- 前端直接 fetch URL 支持 localhost/内网地址
- CORS 失败自动回退到服务端代理 /api/fetch-spec
- 添加 js-yaml 支持 YAML 格式解析
- 服务端移除 specUrl 参数,只接收已解析的 spec 对象

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:28:53 +08:00
49ca1f6e1f feat: OAuth 登录后返回来源页 + 登录页清理
- OAuth 流程透传 redirect 参数,登录后回到触发页面而非固定跳 Dashboard
- 服务端校验 redirect 为相对路径,防止 Open Redirect 攻击
- 隐藏 Apple 登录按钮和邮箱注册入口
- Dark Mode 切换改为下拉菜单样式
- 提取 useClickOutside hook 消除重复代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:56:57 +08:00
d1ee0bbad2 chore: 加固生产部署配置
- 移除未使用的 Redis 服务
- 移除 PostgreSQL 端口暴露,仅保留 Docker 内部访问
- server 添加 healthcheck,mcp 依赖 server 确保 migration 完成后启动
- .dockerignore 排除 .env 等敏感文件
- .env.example 对齐实际所需字段

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:43:09 +08:00
5d199c4c5c fix: 添加数据库迁移补齐 User API Key 字段
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:36:10 +08:00
5e6efdaf59 fix: Docker 构建改用 npm 替代 pnpm + 补全 OAuth/Redis 环境变量
- Dockerfile 全部改为 npm install + 全局 tsc,解决 pnpm 符号链接问题
- docker-compose 添加 Redis 服务、OAuth 环境变量透传、web 端口改为可配置
- MCP URL 改用 window.location.origin 适配反向代理
- tsconfig 添加 paths 映射解决 Docker 内模块引用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:24:52 +08:00
8b6aeb28b1 feat: 支持 OAuth 无密码用户设置密码和查看 API Key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:54 +08:00
9733b82c9c feat: 支持 OAuth 无密码用户设置密码和查看 API Key
- 新增 POST /auth/set-password 端点(仅限无密码用户)
- /auth/me 返回 hasPassword 字段
- SettingsDialog:无密码用户显示"设置密码"表单(无需当前密码)
- API Key reveal/copy:无密码时引导用户先设置密码
- 中英双语 i18n 支持

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:46 +08:00
a9a7216447 feat: 登录页重设计 + Google/GitHub/Apple OAuth 登录支持
- 登录/注册页改为左右分栏布局(左侧品牌展示,右侧表单)
- 新增 Google、GitHub、Apple 三方 OAuth 登录(标准授权码流程)
- 后端:OAuth 路由、Provider 配置、用户查找/创建逻辑
- 前端:AuthBranding、OAuthButtons、LoginCallback 组件
- 移动端自适应(品牌区隐藏,显示简版标题)
- 完整中英双语 i18n 支持

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:31:53 +08:00
8ed857c31c merge: 合并 main 分支的 i18n 重构到 login-page 功能分支
解决冲突:
- i18n.tsx: 采用 main 的独立文件架构(en.ts/zh.ts),新增 OAuth/branding 翻译 key
- Login.tsx: 保留左右分栏布局,合入 main 的验证消息 i18n 化
- Register.tsx: 同上,合入 main 的 placeholder i18n 化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:31:41 +08:00
9b41878ae7 refactor: 优化 i18n 类型安全与渲染性能
- 导出 TranslationKey 类型,翻译 key 拼写错误编译期即报错
- zh.ts 使用 TranslationKey 约束,确保中英文 key 同步
- useMemo 包装 context value,避免不必要的全局重渲染
- ConfirmDialog confirmText 默认值改用 t() 而非硬编码英文
- SchemaProperties 递归组件改为 prop 传递 t,减少 useContext 调用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 13:26:57 +08:00
eacaa5be05 refactor: simplify OAuth routes, add type safety, deduplicate UI components
- Extract handleOAuthCallback to eliminate GET/POST duplication in oauth.ts
- Add P2002 race condition handling in findOrCreateUser
- Add .unref() to stateStore cleanup timer to not block process exit
- Use Provider union type instead of bare strings throughout OAuth code
- Export API_BASE from api.ts, reuse in OAuthButtons
- Extract MobileBranding component to deduplicate Login/Register mobile brand
- Extract shared Logo component in AuthBranding
- Remove unnecessary WHAT comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:25:50 +08:00
0bab0ecb93 feat: redesign register page with left-right split layout and OAuth buttons 2026-04-03 13:18:07 +08:00
db4e5540ad feat: redesign login page with left-right split layout and OAuth buttons 2026-04-03 13:18:05 +08:00
a7027c8aaa feat: add LoginCallback page and route for OAuth redirect handling 2026-04-03 13:17:53 +08:00
9316795e4f feat: add OAuth routes for Google, GitHub, and Apple login
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:16:06 +08:00
0a48152e0f feat: add AuthBranding and OAuthButtons components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:15:56 +08:00
6d633eeac4 feat: add i18n translation keys for auth pages 2026-04-03 13:15:42 +08:00
7f44bc8e32 feat: add loginWithTokens method to auth context for OAuth flow 2026-04-03 13:15:38 +08:00
2d07ac6cd4 feat: add OAuth provider configuration and token exchange utilities 2026-04-03 13:13:21 +08:00
67295c22d1 feat: 全面支持中英文多语言切换
将翻译文件拆分为独立的 en.ts/zh.ts,为 t() 函数添加插值支持,
国际化 Dashboard 全部页面和组件(登录、注册、项目管理、设置、
MCP 集成等),修复 ThemeToggle 仅中文标签的 bug,
在 Dashboard header 中添加 LanguageToggle 组件。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 13:10:09 +08:00
dace447a14 docs: add login page and OAuth implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:00:33 +08:00
3c53bf08bb docs: add login page redesign and OAuth support design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:55:44 +08:00
4b3a9481c6 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:40:34 +08:00
1712b25748 fix: 修复项目相关页面路由前缀为 /dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 12:29:57 +08:00
7e691a8100 feat: new home web 2026-04-03 00:02:09 +08:00
35511eb877 feat: opt web ux 2026-04-02 22:10:24 +08:00
143b1e8c4b feat: optimize web ui 2026-04-02 18:22:14 +08:00
130 changed files with 11491 additions and 418 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.worktrees
.claude
.git
.env
.env.*
!.env.example
dist
docs
*.zip
*.md
!README.md

View File

@@ -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

3
.gitignore vendored
View File

@@ -30,6 +30,9 @@ docker-compose.override.yml
# Claude Code # Claude Code
.claude/ .claude/
# Git worktrees
.worktrees/
# Prisma # Prisma
prisma/*.db prisma/*.db
prisma/*.db-journal prisma/*.db-journal

View File

@@ -38,7 +38,8 @@ pnpm monorepo with 4 packages sharing TypeScript config (`tsconfig.base.json`):
### Data Flow ### Data Flow
1. User imports OpenAPI doc (JSON/YAML/URL) via web UI 1. User imports OpenAPI doc (JSON/YAML/URL) via web UI
2. Server validates with `@apidevtools/swagger-parser`, dereferences all `$ref`s 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 3. Parses into Module (from tags or path prefixes) and Endpoint records in PostgreSQL
4. User gets a project ID + API key 4. User gets a project ID + API key
5. LLM connects to MCP service at `/mcp/:projectId` with API key 5. LLM connects to MCP service at `/mcp/:projectId` with API key
@@ -58,9 +59,20 @@ The 5 tools are designed for minimal token usage per call (~200-2000 tokens each
- **API responses**: All endpoints return `{ success: boolean, data?: T, error?: { code, message } }` - **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). - **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)`. - **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. - **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 `deps` build stage + volume mounts for hot reload. Web uses Vite `build` stage. Shared must be built inside container before server/mcp start. - **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`) ### Database (Prisma schema at `prisma/schema.prisma`)

View File

@@ -7,7 +7,7 @@ services:
build: build:
context: . context: .
dockerfile: packages/server/Dockerfile dockerfile: packages/server/Dockerfile
target: deps target: build
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
command: > command: >
@@ -33,7 +33,7 @@ services:
build: build:
context: . context: .
dockerfile: packages/mcp/Dockerfile dockerfile: packages/mcp/Dockerfile
target: deps target: build
command: > command: >
sh -c " sh -c "
npx prisma generate --schema=prisma/schema.prisma && npx prisma generate --schema=prisma/schema.prisma &&
@@ -51,6 +51,26 @@ services:
MCP_PORT: "3001" MCP_PORT: "3001"
NODE_ENV: development 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: web:
build: build:
context: . context: .

View File

@@ -7,8 +7,6 @@ services:
POSTGRES_DB: agentfox POSTGRES_DB: agentfox
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentfox"] test: ["CMD-SHELL", "pg_isready -U agentfox"]
interval: 5s interval: 5s
@@ -23,12 +21,25 @@ services:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
JWT_SECRET: ${JWT_SECRET:-change-me-in-production} JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-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" SERVER_PORT: "3000"
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
mcp: mcp:
build: build:
@@ -40,18 +51,24 @@ services:
ports: ports:
- "3001:3001" - "3001:3001"
depends_on: depends_on:
postgres: server:
condition: service_healthy condition: service_healthy
docs:
build:
context: ./docs
dockerfile: Dockerfile
web: web:
build: build:
context: . context: .
dockerfile: packages/web/Dockerfile dockerfile: packages/web/Dockerfile
ports: ports:
- "80:80" - "${WEB_PORT:-8088}:80"
depends_on: depends_on:
- server - server
- mcp - mcp
- docs
volumes: volumes:
pgdata: pgdata:

6
docs/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.worktrees
.claude
.git
dist
*.zip

26
docs/Dockerfile Normal file
View 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
View File

@@ -0,0 +1,39 @@
# AgentFox 文档
> **AgentFox** — 为 LLM 而生的 API 文档服务
AgentFox 将你的 OpenAPI / Swagger 文档转换为 MCPModel 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
View 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
View 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
View 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`

View 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。

View 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
View 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
View 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
View 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
```

View 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 泄露。

View File

@@ -0,0 +1,82 @@
# 其他 MCP 客户端
任何支持 MCP 协议 Streamable HTTP 传输的 AI 工具都可以连接 AgentFox。
## 连接要素
连接 AgentFox 只需要三个信息:
| 要素 | 值 |
|------|-----|
| **传输类型** | HTTPStreamable 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发送请求、GETSSE 会话恢复、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
View 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
View 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生产必须
### 方案 ACaddy推荐自动 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
View File

@@ -0,0 +1,47 @@
# 常见问题
## 什么是 MCPAgentFox 如何使用它?
MCPModel 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 Keybcrypt 哈希加密从不以明文存储。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 即可。

View 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 工具获取所需信息,无需你手动复制文档。

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

View 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) 了。

View 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 服务。

View 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)。

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

View File

@@ -0,0 +1,65 @@
# MCP 协议介绍
MCPModel 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
View File

@@ -0,0 +1,48 @@
# MCP 工具概述
AgentFox 提供 5 个 MCP 工具,采用渐进式下钻设计,让 LLM 按需获取精确信息。
## 设计理念
传统方式是将完整 API 规范一次性提供给 LLM10,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) — 按关键词搜索端点

View 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 消耗最多的工具,但也是信息最丰富的。
## 使用场景
- 需要了解如何调用某个具体端点
- 查看请求参数的类型、是否必填、描述
- 查看响应格式以便解析返回数据

View 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`

View 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 提供了哪些操作

View 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`

View 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
View 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
View 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 Appcallback 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` 页面点击对应按钮即可测试。

View 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
- 如果怀疑密钥泄露,立即轮换

View 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 更快定位到所需的端点。

View 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 或文件上传

File diff suppressed because it is too large Load Diff

View 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".

View 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

View File

@@ -1,36 +1,35 @@
FROM node:20-alpine AS base FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate RUN npm install -g typescript@5
WORKDIR /app WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/shared/tsconfig.json packages/shared/
COPY packages/mcp/package.json packages/mcp/
COPY packages/mcp/tsconfig.json packages/mcp/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/ ./
COPY packages/shared/ packages/shared/
COPY packages/mcp/ packages/mcp/
COPY prisma/ prisma/
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
RUN npx prisma generate --schema=prisma/schema.prisma COPY prisma/ prisma/
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/mcp build
# 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 FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/ 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/dist ./packages/mcp/dist
COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
COPY --from=build /app/packages/mcp/package.json ./packages/mcp/ COPY --from=build /app/packages/mcp/package.json ./packages/mcp/
COPY --from=build /app/prisma ./prisma COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
WORKDIR /app/packages/mcp WORKDIR /app/packages/mcp
EXPOSE 3001 EXPOSE 3001

View File

@@ -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();
} }

View File

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

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

View File

@@ -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;

View File

@@ -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"]
} }

View File

@@ -1,40 +1,39 @@
FROM node:20-alpine AS base FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate RUN npm install -g typescript@5
WORKDIR /app WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/shared/tsconfig.json packages/shared/
COPY packages/server/package.json packages/server/
COPY packages/server/tsconfig.json packages/server/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/ ./
COPY packages/shared/ packages/shared/
COPY packages/server/ packages/server/
COPY prisma/ prisma/
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
RUN npx prisma generate --schema=prisma/schema.prisma COPY prisma/ prisma/
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/server build
# 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 FROM node:20-alpine AS runtime
RUN npm install -g prisma@6
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/ 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/dist ./packages/server/dist
COPY --from=build /app/packages/server/node_modules ./packages/server/node_modules
COPY --from=build /app/packages/server/package.json ./packages/server/ 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 --from=build /app/prisma ./prisma
COPY scripts/migrate-and-start.sh ./scripts/ COPY scripts/migrate-and-start.sh ./scripts/
RUN chmod +x scripts/migrate-and-start.sh RUN chmod +x scripts/migrate-and-start.sh
RUN npm install -g prisma@6
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "scripts/migrate-and-start.sh"] CMD ["sh", "scripts/migrate-and-start.sh"]

View File

@@ -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, () => {

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

View File

@@ -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 {

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

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

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

View File

@@ -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;

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

View File

@@ -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) => {

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

View File

@@ -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;

View File

@@ -115,18 +115,8 @@ function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[];
} }
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> { export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
let specInput: string | object = input; // SwaggerParser.bundle handles URLs, JSON objects, and YAML strings natively
const bundled = await SwaggerParser.bundle(input as any) as OpenAPI.Document;
// If input is a URL, fetch the content first so that swagger-parser
// works on a plain object and doesn't need network access for $ref resolution
if (typeof input === 'string' && input.startsWith('http')) {
const res = await fetch(input);
if (!res.ok) throw new Error(`Failed to fetch spec from URL: ${res.status} ${res.statusText}`);
specInput = await res.json();
}
// Bundle resolves all $refs into a single document, then dereference inlines them
const bundled = await SwaggerParser.bundle(specInput as any) as OpenAPI.Document;
const api = await SwaggerParser.dereference(bundled, { const api = await SwaggerParser.dereference(bundled, {
dereference: { circular: 'ignore' }, dereference: { circular: 'ignore' },
}) as OpenAPI.Document; }) as OpenAPI.Document;

View File

@@ -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"]
} }

View File

@@ -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;

View File

@@ -1,14 +1,16 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app/packages/web
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY packages/web/package.json ./
COPY packages/web/package.json packages/web/ RUN npm install
RUN pnpm install --frozen-lockfile --filter @agent-fox/web...
COPY packages/web/ packages/web/ COPY packages/web/src/ ./src/
COPY tsconfig.base.json ./ COPY packages/web/index.html ./
RUN pnpm --filter @agent-fox/web build COPY packages/web/vite.config.ts ./
COPY packages/web/tsconfig.json ./
COPY tsconfig.base.json /app/
RUN npx vite build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/packages/web/dist /usr/share/nginx/html COPY --from=build /app/packages/web/dist /usr/share/nginx/html

View File

@@ -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>

View File

@@ -7,18 +7,27 @@ server {
proxy_pass http://server:3000; proxy_pass http://server:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location /mcp/ { location /mcp/ {
proxy_pass http://mcp:3001; proxy_pass http://mcp:3001;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; 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_http_version 1.1;
proxy_set_header Connection ''; proxy_set_header Connection '';
proxy_buffering off; proxy_buffering off;
proxy_cache 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -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"

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
type SkeletonProps = {
className?: string;
};
export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
return <div className={`skeleton ${className}`} />;
}

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

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

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

View File

@@ -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;
}
}

View File

@@ -1,4 +1,4 @@
const API_BASE = '/api'; export const API_BASE = '/api';
type ApiResponse<T> = { type ApiResponse<T> = {
success: boolean; success: boolean;

View File

@@ -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>
); );

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

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

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

View 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 Keybcrypt 哈希加密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': '什么是 MCPAgentFox 如何使用它?',
'faq.1.a': 'MCPModel 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 Keybcrypt 哈希加密从不以明文存储。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;

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

View File

@@ -1,11 +1,13 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { fetchSpecFromUrl } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n';
import Modal from '../components/Modal';
type ImportResult = { type ImportResult = {
project: { id: string; name: string }; project: { id: string; name: string };
apiKey: string;
stats: { modules: number; endpoints: number }; stats: { modules: number; endpoints: number };
}; };
@@ -13,27 +15,38 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
const [mode, setMode] = useState<'url' | 'file'>('url'); const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState<string>(''); const [fileContent, setFileContent] = useState<string>('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [result, setResult] = useState<ImportResult | null>(null); const [result, setResult] = useState<ImportResult | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useI18n();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFile = (file: File) => {
const file = e.target.files?.[0]; setFileName(file.name);
if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string); reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file); reader.readAsText(file);
}; };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleImport = async () => { const handleImport = async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
let body: Record<string, unknown>; let body: Record<string, unknown>;
if (mode === 'url') { if (mode === 'url') {
body = { specUrl: url }; const spec = await fetchSpecFromUrl(url);
body = { spec };
} else { } else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
} }
@@ -50,47 +63,91 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
}; };
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <Modal open onClose={onClose} size="md">
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
{!result ? ( {!result ? (
<> <div className="space-y-5">
<h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2> <div>
<div className="flex gap-2 mb-4"> <h2 className="text-[15px] font-semibold text-text-primary">{t('dashboard.import.title')}</h2>
<button onClick={() => setMode('url')} className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>From URL</button> <p className="section-desc">{t('dashboard.import.desc')}</p>
<button onClick={() => setMode('file')} className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>Upload File</button>
</div> </div>
{/* Mode toggle */}
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary max-w-fit border border-border-muted">
<button onClick={() => setMode('url')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>{t('common.fromUrl')}</button>
<button onClick={() => setMode('file')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>{t('common.uploadFile')}</button>
</div>
{mode === 'url' ? ( {mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" /> <input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
) : ( ) : (
<input type="file" accept=".json,.yaml,.yml" onChange={handleFileChange} className="w-full mb-4" /> <div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
}`}
>
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
{fileName ? (
<p className="text-[13px] text-text-primary font-medium">{fileName}</p>
) : (
<>
<p className="text-[13px] text-text-secondary">{t('common.dropFile')}</p>
<p className="text-[11px] text-text-muted mt-1">{t('common.jsonOrYaml')}</p>
</>
)} )}
{error && <p className="text-red-500 text-sm mb-4">{error}</p>} </div>
<div className="flex justify-end gap-2"> )}
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">Cancel</button>
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)} {error && (
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"> <div className="p-3 rounded-lg bg-danger-muted flex items-center gap-2">
{loading ? 'Importing...' : 'Import'} <svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<div className="flex justify-end gap-2.5">
<button onClick={onClose} className="btn-ghost">{t('common.cancel')}</button>
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
{loading ? (
<><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('common.importing')}</>
) : t('common.import')}
</button> </button>
</div> </div>
</> </div>
) : ( ) : (
<> <div className="space-y-5">
<h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2> <div className="flex items-center gap-3">
<div className="space-y-3 text-sm"> <div className="w-10 h-10 rounded-xl bg-success-muted flex items-center justify-center">
<p><strong>Project:</strong> {result.project.name}</p> <svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
<p><strong>Modules:</strong> {result.stats.modules}</p> </div>
<p><strong>Endpoints:</strong> {result.stats.endpoints}</p> <div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded"> <h2 className="text-[15px] font-semibold text-text-primary">{t('dashboard.import.successTitle')}</h2>
<p className="font-medium text-yellow-800 mb-1">API Key (save it now):</p> <p className="text-[13px] text-text-muted">{result.project.name}</p>
<code className="text-xs break-all">{result.apiKey}</code>
</div> </div>
</div> </div>
<div className="flex justify-end mt-4">
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Go to Project</button> <div className="grid grid-cols-2 gap-3">
<div className="card p-4 text-center">
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.modules}</div>
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">{t('common.modules')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.endpoints}</div>
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">{t('common.endpoints')}</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">{t('dashboard.import.goToProject')}</button>
</div>
</div> </div>
</>
)} )}
</div> </Modal>
</div>
); );
} }

View File

@@ -1,22 +1,329 @@
import { Navigate, Outlet } from 'react-router-dom'; import { useState, useRef, useEffect } from 'react';
import { Navigate, Outlet, NavLink, Link, useLocation, useParams, useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useI18n } from '../lib/i18n';
import { apiFetch } from '../lib/api';
import ThemeToggle from '../components/ThemeToggle';
import LanguageToggle from '../components/LanguageToggle';
import SettingsDialog from '../components/SettingsDialog';
import ConfirmDialog from '../components/ConfirmDialog';
export default function Layout() { type LayoutContext = { onOpenSettings: () => void };
const { user, loading, logout } = useAuth(); export function useLayoutContext() { return useOutletContext<LayoutContext>(); }
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>; type ProjectSummary = {
if (!user) return <Navigate to="/login" replace />; id: string; name: string; description: string | null;
_count: { endpoints: number; modules: number };
};
function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string; role?: string }; logout: () => void; onOpenSettings: () => void }) {
const [open, setOpen] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { t } = useI18n();
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return ( return (
<div className="min-h-screen bg-gray-50"> <div ref={ref} className="relative">
<header className="bg-white border-b px-6 py-3 flex items-center justify-between"> <button
<h1 className="text-lg font-semibold">Agent Fox</h1> onClick={() => setOpen(!open)}
<div className="flex items-center gap-4"> className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-tertiary transition-colors"
<span className="text-sm text-gray-600">{user.name}</span> >
<button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button> <div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
{initials}
</div> </div>
</header> <div className="hidden md:block text-left">
<main className="p-6"><Outlet /></main> <div className="text-[13px] font-medium text-text-primary leading-tight">{user.name}</div>
</div>
<svg className="w-3.5 h-3.5 text-text-muted hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="user-dropdown">
{/* User info */}
<div className="px-3 py-2.5 border-b border-border-muted">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[11px] font-bold tracking-wide shrink-0">
{initials}
</div>
<div className="min-w-0">
<div className="text-[13px] font-medium text-text-primary truncate">{user.name}</div>
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
</div>
</div>
</div>
{/* Actions */}
<div className="py-1">
{user.role === 'ADMIN' && (
<Link
to="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-accent hover:bg-accent-muted transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
Admin
</Link>
)}
<button
onClick={() => { setOpen(false); onOpenSettings(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<circle cx="12" cy="12" r="3" />
</svg>
{t('common.settings')}
</button>
<button
onClick={() => { setOpen(false); setConfirmLogout(true); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
{t('common.signOut')}
</button>
</div>
</div>
)}
<ConfirmDialog
open={confirmLogout}
onConfirm={() => { setConfirmLogout(false); logout(); }}
onCancel={() => setConfirmLogout(false)}
title={t('common.signOut')}
description={t('common.signOutConfirm')}
confirmText={t('common.signOut')}
variant="warning"
/>
</div>
);
}
function ProjectSidebar() {
const location = useLocation();
const params = useParams();
const activeProjectId = params.id;
const { t } = useI18n();
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
});
const isProjectsRoot = location.pathname === '/dashboard';
return (
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
{/* Sidebar header */}
<div className="px-4 h-12 flex items-center justify-between border-b border-border-muted shrink-0">
<span className="section-label">{t('dashboard.layout.projects')}</span>
</div>
{/* Project list */}
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<NavLink
to="/dashboard"
end
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isProjectsRoot
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`}
>
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
{t('dashboard.layout.allProjects')}
</NavLink>
{projects && projects.length > 0 && (
<div className="border-t border-border-muted my-2!" />
)}
{isLoading && (
<div className="space-y-1.5 px-1">
{[1, 2, 3].map(i => (
<div key={i} className="h-8 rounded-lg skeleton" />
))}
</div>
)}
{projects?.map((p) => (
<NavLink
key={p.id}
to={`/dashboard/projects/${p.id}`}
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] transition-all duration-150 group ${
activeProjectId === p.id
? 'bg-accent-muted text-accent font-medium'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`}
>
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{p.name}</span>
<span className="ml-auto text-[11px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{p._count.endpoints}
</span>
</NavLink>
))}
</nav>
</aside>
);
}
function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) {
const [dismissed, setDismissed] = useState(() => localStorage.getItem('agent-fox-onboarding-dismissed') === 'true');
const { t } = useI18n();
const { data: keyStatus } = useQuery({
queryKey: ['api-key-status'],
queryFn: () => apiFetch<{ hasKey: boolean }>('/auth/api-key/status'),
});
if (dismissed || keyStatus?.hasKey) return null;
// Still loading
if (!keyStatus) return null;
return (
<div className="mb-6 p-4 rounded-xl bg-accent-muted border border-accent/20 flex items-center gap-4 animate-fade-in">
<svg className="w-5 h-5 text-accent 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>
<div className="flex-1 min-w-0">
<p className="text-[13px] text-text-primary font-medium">{t('dashboard.layout.onboardingTitle')}</p>
<p className="text-[12px] text-text-secondary mt-0.5">{t('dashboard.layout.onboardingDesc')}</p>
</div>
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5">
{t('dashboard.layout.generateApiKey')}
</button>
<button
onClick={() => { setDismissed(true); localStorage.setItem('agent-fox-onboarding-dismissed', 'true'); }}
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors shrink-0"
>
<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>
);
}
export default function Layout() {
const { user, loading, logout } = useAuth();
const { t } = useI18n();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg-secondary">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
return (
<div className="h-screen bg-bg-secondary flex flex-col overflow-hidden">
{/* Top Header — fixed */}
<header className="h-14 border-b border-border-default bg-bg-sidebar flex items-center px-4 lg:px-5 shrink-0 z-30">
{/* Left: mobile menu + logo */}
<div className="flex items-center gap-3">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="lg:hidden p-1.5 -ml-1.5 text-text-secondary hover:text-text-primary rounded-md"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Link to="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
</Link>
</div>
{/* Right: language toggle + theme toggle + user */}
<div className="ml-auto flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<div className="w-px h-5 bg-border-default mx-1" />
<UserDropdown user={user} logout={logout} onOpenSettings={() => setSettingsOpen(true)} />
</div>
</header>
{/* Body: sidebar + main — fills remaining height */}
<div className="flex-1 flex min-h-0">
{/* Mobile sidebar overlay */}
{mobileMenuOpen && (
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setMobileMenuOpen(false)} />
)}
{/* Mobile sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 w-[260px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:hidden ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Link to="/" className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
</Link>
<nav className="flex-1 overflow-y-auto px-2.5 py-3 space-y-0.5">
<NavLink
to="/dashboard"
end
onClick={() => setMobileMenuOpen(false)}
className={({ isActive }) =>
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isActive
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{t('dashboard.layout.projects')}
</NavLink>
</nav>
</aside>
{/* Desktop project sidebar — stays fixed, has its own scroll */}
<ProjectSidebar />
{/* Main content — only this area scrolls */}
<main className="flex-1 overflow-y-auto min-w-0">
<div className="p-5 lg:p-8 animate-fade-in">
<OnboardingBanner onOpenSettings={() => setSettingsOpen(true)} />
<Outlet context={{ onOpenSettings: () => setSettingsOpen(true) } satisfies LayoutContext} />
</div>
</main>
</div>
{/* Settings dialog */}
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div> </div>
); );
} }

View File

@@ -1,38 +1,129 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useI18n } from '../lib/i18n';
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
import OAuthButtons from '../components/OAuthButtons';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirect') || '/dashboard';
const { t } = useI18n();
const validate = () => {
const errors: { email?: string; password?: string } = {};
if (!email.trim()) {
errors.email = t('auth.login.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = t('auth.login.emailInvalid');
}
if (!password) {
errors.password = t('auth.login.passwordRequired');
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!validate()) return;
setLoading(true);
try { try {
await login(email, password); await login(email, password);
navigate('/'); navigate(redirectTo);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed'); setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow"> <AuthBranding />
<h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>} <div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
<form onSubmit={handleSubmit} className="space-y-4"> <div className="absolute inset-0" style={{
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required /> backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" required /> backgroundSize: '48px 48px',
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Sign In</button> }} />
</form> <div className="absolute inset-0" style={{
<p className="text-center text-sm mt-4"> background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link> }} />
<div className="w-full max-w-[400px] relative animate-slide-up">
<MobileBranding />
<div className="hidden lg:block mb-8">
<h1 className="text-2xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.login.title')}</h1>
</div>
<div className="card p-6 shadow-md">
{error && (
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.email')}</label>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }}
className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
placeholder="you@example.com"
/>
{fieldErrors.email && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.email}
</p> </p>
)}
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.password')}</label>
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })); }}
className={`input-base ${fieldErrors.password ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
placeholder={t('auth.login.passwordPlaceholder')}
/>
{fieldErrors.password && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.password}
</p>
)}
</div>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? (
<><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('auth.login.submitting')}</>
) : t('auth.login.submit')}
</button>
</form>
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-border-default" />
<span className="text-[12px] text-text-muted">{t('auth.login.or')}</span>
<div className="flex-1 h-px bg-border-default" />
</div>
<OAuthButtons redirectTo={redirectTo} />
</div>
{/* Sign Up 入口暂时隐藏,待添加验证码后恢复 */}
</div>
</div> </div>
</div> </div>
); );

Some files were not shown because too many files have changed in this diff Show More