From 9e362801a5d92be87e90cd6a8f7dfa412e5fce38 Mon Sep 17 00:00:00 2001 From: JackCC Date: Thu, 16 Jul 2026 16:55:24 +0800 Subject: [PATCH] feat: rewrite backend in Java --- .env.example | 3 - .github/workflows/ci.yml | 31 ++ .github/workflows/submit.yml | 35 +- .gitignore | 2 + CONTRIBUTING.md | 102 ++--- README.md | 352 +++++++++--------- assets/workflow-overview.svg | 20 +- backend/.dockerignore | 3 + backend/.env.example | 12 + backend/Dockerfile | 11 + backend/pom.xml | 48 +++ .../aiprhelper/AiPrHelperApplication.java | 15 + .../jackcc/aiprhelper/api/ApiException.java | 17 + .../api/GlobalExceptionHandler.java | 46 +++ .../api/PrDescriptionController.java | 48 +++ .../aiprhelper/config/AppProperties.java | 180 +++++++++ .../aiprhelper/config/HttpClientConfig.java | 19 + .../jackcc/aiprhelper/config/WebConfig.java | 24 ++ .../security/BackendTokenFilter.java | 68 ++++ .../jackcc/aiprhelper/service/AiClient.java | 201 ++++++++++ .../aiprhelper/service/GitHubDiffService.java | 165 ++++++++ .../service/PrDescriptionService.java | 59 +++ backend/src/main/resources/application.yml | 23 ++ .../AiPrHelperApplicationTests.java | 12 + .../aiprhelper/service/AiClientTest.java | 44 +++ .../service/GitHubDiffServiceTest.java | 55 +++ compose.yaml | 10 + lib/ai.ts | 253 ------------- lib/backend.ts | 82 ++++ lib/settings.ts | 98 ++--- package.json | 6 +- popup.tsx | 173 ++++----- tsconfig.tsbuildinfo | 1 - 33 files changed, 1518 insertions(+), 700 deletions(-) delete mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/AiPrHelperApplication.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/api/ApiException.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/api/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/api/PrDescriptionController.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/config/AppProperties.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/config/HttpClientConfig.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/config/WebConfig.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/security/BackendTokenFilter.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/service/AiClient.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/service/GitHubDiffService.java create mode 100644 backend/src/main/java/dev/jackcc/aiprhelper/service/PrDescriptionService.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/test/java/dev/jackcc/aiprhelper/AiPrHelperApplicationTests.java create mode 100644 backend/src/test/java/dev/jackcc/aiprhelper/service/AiClientTest.java create mode 100644 backend/src/test/java/dev/jackcc/aiprhelper/service/GitHubDiffServiceTest.java create mode 100644 compose.yaml delete mode 100644 lib/ai.ts create mode 100644 lib/backend.ts delete mode 100644 tsconfig.tsbuildinfo diff --git a/.env.example b/.env.example deleted file mode 100644 index 9ab6c27..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -PLASMO_PUBLIC_AI_API_KEY=your_api_key_here -PLASMO_PUBLIC_AI_BASE_URL=https://api.moonshot.cn/v1 -PLASMO_PUBLIC_AI_MODEL=moonshot-v1-8k diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..941aada --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + - name: Test Java backend + working-directory: backend + run: mvn --batch-mode --no-transfer-progress verify + + extension: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci + - run: npm run build diff --git a/.github/workflows/submit.yml b/.github/workflows/submit.yml index f4de392..2acee23 100644 --- a/.github/workflows/submit.yml +++ b/.github/workflows/submit.yml @@ -1,4 +1,5 @@ -name: "Submit to Web Store" +name: Submit to Web Store + on: workflow_dispatch: @@ -6,28 +7,18 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Cache pnpm modules - uses: actions/cache@v3 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}- - - uses: pnpm/action-setup@v2.2.4 - with: - version: latest - run_install: true - - name: Use Node.js 16.x - uses: actions/setup-node@v3.4.1 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 16.x - cache: "pnpm" - - name: Build the extension - run: pnpm build - - name: Package the extension into a zip artifact - run: pnpm package - - name: Browser Platform Publish + node-version: "20" + cache: npm + - name: Install dependencies + run: npm ci + - name: Build extension + run: npm run build + - name: Package extension + run: npm run package + - name: Publish to browser platforms uses: PlasmoHQ/bpp@v3 with: keys: ${{ secrets.SUBMIT_KEYS }} diff --git a/.gitignore b/.gitignore index 5398ed8..441a56d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,12 @@ yarn-error.log* # local env files .env .env*.local +backend/.env out/ build/ dist/ +backend/target/ # plasmo .plasmo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9619b7d..ab0093a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,98 +1,70 @@ # Contributing -感谢你愿意参与 `AI PR Helper`。 - -这个项目目前还处在早期阶段,欢迎任何能让它更稳定、更好用、更适合开源协作的改进,包括功能开发、Bug 修复、文档完善、测试补充和体验优化。 - -## 适合贡献的方向 - -- 修复 GitHub 页面兼容性问题 -- 优化 PR 描述生成的 Prompt -- 拆分和重构当前前端逻辑 -- 增加设置页和更多可配置项 -- 提升错误提示和空状态体验 -- 补充测试、文档和示例 +感谢你愿意参与 `AI PR Helper`。项目现在由浏览器扩展和 Java 后端两部分组成,欢迎功能开发、Bug 修复、文档完善、测试和安全改进。 ## 本地开发 -### 环境要求 +### 环境 -- Node.js 18+ -- npm +- Node.js 18+ 和 npm +- Java 21 和 Maven 3.6.3+,或 Docker - Chromium 浏览器 -- 可用的 AI API Key +- 用于手动端到端测试的 AI API Key -### 安装依赖 +### Java 后端 ```bash -npm install +cd backend +export AI_API_KEY=your_api_key_here +mvn spring-boot:run ``` -### 配置环境变量 +也可以在仓库根目录复制 `backend/.env.example` 为 `backend/.env`,然后运行: ```bash -cp .env.example .env +docker compose up --build ``` -填写: +不要提交真实的 `.env`、AI API Key、GitHub Token 或后端访问令牌。 + +### 浏览器扩展 ```bash -PLASMO_PUBLIC_AI_API_KEY=your_api_key_here -PLASMO_PUBLIC_AI_BASE_URL=https://api.moonshot.cn/v1 -PLASMO_PUBLIC_AI_MODEL=moonshot-v1-8k +npm install +npm run dev ``` -默认值仍然兼容 Kimi;如果你接的是其他兼容 OpenAI Chat Completions 的服务,把地址和模型名替换掉即可。 +将 `build/chrome-mv3-dev` 作为未打包扩展加载,并在设置页填写 Java 后端地址。 -### 启动开发 +## 架构边界 -```bash -npm run dev -``` +- `backend/` 负责 GitHub Diff 抓取、Prompt、模型请求、重试、安全和 API 错误响应。 +- `lib/backend.ts` 负责扩展与 Java API 的通信。 +- `popup.tsx` 负责浏览器交互、预览和 GitHub 页面回填。 +- AI API Key 不应重新放回浏览器端。 -然后到浏览器扩展页面加载开发目录,例如: +## 提交前检查 ```bash -build/chrome-mv3-dev +npm run backend:test +npm run build ``` -## 提交改动前建议 +涉及真实模型调用时,还应手动验证: -- 先确认改动是否符合当前项目目标:帮助 GitHub PR 更快生成高质量描述 -- 尽量保持改动聚焦,一个 PR 只解决一类问题 -- 如果改动涉及交互行为,请在 PR 描述中说明操作路径和预期效果 -- 如果改动涉及 prompt 或输出格式,请提供前后对比示例 -- 如果改动依赖 GitHub 页面 DOM,请说明测试过的页面类型 +1. `/api/health` 能正确反映 AI 是否已配置 +2. `/api/ai/test` 能完成模型连接测试 +3. PR 页面和 Compare 页面都能生成描述 +4. GitHub 描述输入框可以正常回填 +5. 错误 Token、无效 URL、GitHub 失败和模型失败都有可理解的提示 ## Pull Request 建议 -提交 PR 时,建议包含这些信息: - -- 改动背景 -- 解决的问题 -- 实现方式 -- 风险或兼容性影响 -- 手动验证步骤 - -如果是 UI 或交互相关改动,最好补上截图或录屏。 - -## 代码风格 - -- 使用 TypeScript -- 保持实现直接、清晰,优先可读性 -- 避免引入和当前项目规模不匹配的复杂抽象 -- 在必要时补充简短注释,帮助后来者理解关键逻辑 - -## 沟通方式 - -如果你准备做较大的改动,建议先发一个 Issue 或先在 PR 中简单说明方案,避免和项目后续方向偏离太多。 - -小修复、小文档修改、拼写优化这类改动可以直接提交 PR。 +PR 请说明: -## 目前特别欢迎的帮助 +- 改了什么以及为什么 +- 对扩展、Java API、配置或部署有何影响 +- 验证命令与手动测试结果 +- 兼容性、安全性或回滚注意事项 -- 为不同类型的 PR 设计更稳的生成模板 -- 增加对超长 Diff 的处理策略 -- 补充自动化测试 -- 降低前端直连模型 API 的安全风险 -- 完善开源仓库基础设施,例如 `LICENSE`、Issue 模板、PR 模板 +如果改动 GitHub DOM 选择器,请写明测试过的页面类型;如果改动 Prompt,请提供前后输出示例。 diff --git a/README.md b/README.md index a0cb1a7..e9e8937 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,54 @@ # AI PR Helper -一个面向 GitHub Pull Request 场景的浏览器扩展。 +一个面向 GitHub Pull Request 场景的浏览器扩展,由 Java 后端安全地完成 Diff 抓取和 AI 调用。 -它会读取当前 PR 或 Compare 页面的 Git Diff,调用兼容 OpenAI Chat Completions 协议的 AI 接口生成结构化 PR 描述,并支持一键回填到 GitHub 的描述输入框里。整个流程都发生在浏览器侧,适合个人开发者和小团队先快速建立更统一的 PR 描述方式,提升提 PR 和代码审查的效率。 +扩展读取当前 PR 或 Compare 页面的 URL,将它交给 Spring Boot 服务;Java 后端抓取 Git Diff、调用兼容 OpenAI Chat Completions 协议的模型,并把结构化 PR 描述返回给扩展。用户可以预览结果,再一键回填到 GitHub 描述输入框。 -## 项目定位 +## 为什么增加 Java 后端 -很多团队的 PR 描述问题,不只是“写起来麻烦”,还包括“每个人写法都不一样”。 +旧版本完全在浏览器侧运行,API Key 保存在 `chrome.storage.local`,Diff 也由扩展直接发送给模型服务。现在这些职责已经迁入 Java 后端: -一方面,它确实很重复: - -- 改了什么 -- 为什么改 -- 影响到哪里 -- 评审时应该重点看什么 - -另一方面,尤其在小团队里,如果没有约定好的 PR 模版或描述标准,大家往往会各写各的: - -- 有人写一句话就提交 -- 有人写很多细节,但缺少结构 -- 有人会写影响范围,有人完全不写 -- Reviewer 每次都要重新理解这次 PR 应该怎么看 - -`AI PR Helper` 想解决的,不只是这类机械劳动,也是在团队里提供一个更稳定的 PR 描述基线。它不是要替代工程判断,而是把“整理 Diff 并组织成文字”的那一步自动化,并尽量按统一结构输出,让你更快得到一份可继续人工润色、也更利于评审的初稿。 - -## 核心能力 - -- 自动识别 GitHub PR 页面和 Compare 页面 -- 自动推导并抓取当前页面对应的 `.diff` -- 将 Diff 发给可配置的 AI 接口生成 PR 描述 -- 在弹窗中预览生成结果 -- 一键把结果粘贴回 GitHub PR 描述输入框 - -当前默认生成的内容包含: - -- `## 变更概述` -- `## 主要改动` -- `## 影响范围` -- `## 风险与回滚` -- `## 测试说明` - -## 使用效果 - -插件弹窗目前覆盖“生成结果预览”和“接口配置”两个核心场景,界面如下: - -

- AI PR Helper 插件截图 1 - AI PR Helper 插件截图 2 -

- -典型使用方式如下: - -1. 在 GitHub 打开一个 Pull Request 页面,或者 Compare 页面 -2. 打开扩展弹窗,点击“`一键生成 PR 描述`” -3. 扩展抓取当前 Diff,并请求已配置的 AI 接口生成总结 -4. 你在弹窗中检查结果 -5. 点击“`一键粘贴到 PR 描述`”,自动写回 GitHub 编辑框 +- AI API Key 只存在于服务端环境变量中 +- Prompt、Diff 截断、重试和模型错误处理集中维护 +- 扩展不再需要模型服务域名权限 +- 可用一个后端为团队统一模型和生成模板 +- 可选配置 GitHub Token,以读取后端有权访问的私有仓库 ## 工作原理 ![AI PR Helper 工作原理图](./assets/workflow-overview.svg) -核心逻辑目前集中在 [`popup.tsx`](/Users/cc/my-github-ai-helper/popup.tsx): +主链路如下: -- 识别支持的 GitHub 页面 -- 请求 Diff 文本 -- 调用兼容 OpenAI Chat Completions 的 AI API -- 使用 `chrome.scripting` 将结果写入 GitHub textarea +1. 扩展读取当前 GitHub PR 或 Compare 页面 URL +2. 扩展调用 Java 后端的 `POST /api/pr-descriptions` +3. 后端校验 URL,仅允许从 `github.com` 抓取对应 Diff +4. 后端截断超长 Diff,组合统一 Prompt,并调用 AI Chat Completions 接口 +5. 扩展预览返回的 Markdown,并可一键写回 GitHub PR 描述框 -## 支持的页面 +## 技术栈 -当前主要支持以下地址格式: +- 浏览器扩展:React 18、TypeScript、Plasmo +- 后端:Java 21、Spring Boot 4.1、Maven +- 模型协议:OpenAI-compatible Chat Completions +- 部署:可执行 JAR 或 Docker -- `https://github.com///pull/` -- `https://github.com///compare/...` +## 项目结构 -如果当前页面不是上述两类页面,扩展会提示无法抓取 Diff。 +```text +. +├── backend/ +│ ├── src/main/java/ # Controller、GitHub Diff、AI Client、配置与安全 +│ ├── src/test/java/ # Java 单元测试和 Spring 上下文测试 +│ ├── Dockerfile +│ └── pom.xml +├── lib/ +│ ├── backend.ts # 扩展调用 Java API +│ └── settings.ts # 后端地址和访问令牌的浏览器本地配置 +├── popup.tsx # 扩展弹窗和 GitHub 回填逻辑 +├── compose.yaml +└── package.json +``` ## 快速开始 @@ -83,193 +56,202 @@ - Node.js 18+ - npm -- Chrome 或其他兼容 Chromium 的浏览器 +- Java 21 和 Maven 3.6.3+,或 Docker +- Chrome 或其他 Chromium 浏览器 - 可用的 AI API Key -### 1. 安装依赖 +### 1. 启动 Java 后端 + +推荐使用 Docker: ```bash -npm install +cp backend/.env.example backend/.env ``` -### 2. 启动开发构建 +在 `backend/.env` 至少填写: -```bash -npm run dev +```dotenv +AI_API_KEY=your_api_key_here +AI_BASE_URL=https://api.moonshot.cn/v1 +AI_MODEL=moonshot-v1-8k +APP_SECURITY_TOKEN=replace_with_a_long_random_token ``` -如果你使用 Chrome Manifest V3,本地加载目录通常是: +然后启动: ```bash -build/chrome-mv3-dev +docker compose up --build ``` -### 3. 在浏览器中加载扩展 - -1. 打开 `chrome://extensions` -2. 开启“开发者模式” -3. 点击“加载已解压的扩展程序” -4. 选择 `build/chrome-mv3-dev` +也可以直接使用本地 Java: -### 4. 在插件里配置你的 AI 接口 +```bash +cd backend +export AI_API_KEY=your_api_key_here +export AI_BASE_URL=https://api.moonshot.cn/v1 +export AI_MODEL=moonshot-v1-8k +mvn spring-boot:run +``` -首次打开扩展后,切换到“`设置`”页,填写并保存: +服务默认监听 `http://localhost:8080`。检查状态: -- `API Key` -- `Base URL`(或直接填 `API URL`) -- `Model` +```bash +curl http://localhost:8080/api/health +``` -保存时扩展会按你填写的接口域名申请权限。配置仅保存在当前浏览器本地存储中(`chrome.storage.local`)。 +如果配置了 `APP_SECURITY_TOKEN`,请求需要携带: -## 日常使用 +```text +X-Backend-Token: <你的令牌> +``` -### 生成 PR 描述 +### 2. 启动浏览器扩展 -1. 打开 GitHub PR 或 Compare 页面 -2. 点击扩展图标 -3. 确保在“`设置`”页已完成 API 配置 -4. 回到“`生成`”页点击“`一键生成 PR 描述`” -5. 等待 AI 返回结果 +在仓库根目录执行: -### 回填到 GitHub +```bash +npm install +npm run dev +``` -1. 确保 GitHub 页面中已经展开 PR 描述编辑区域 -2. 点击“`一键粘贴到 PR 描述`” -3. 扩展会自动填充内容到对应文本框 +在 Chrome 的 `chrome://extensions` 中开启开发者模式,点击“加载已解压的扩展程序”,选择: -如果没有找到输入框,通常意味着: +```text +build/chrome-mv3-dev +``` -- 当前页面不是 PR 描述可编辑场景 -- GitHub 编辑器还没有展开 -- GitHub 页面 DOM 结构发生了变化 +### 3. 配置扩展 -## 插件内配置项 +打开扩展的“设置”页: -| 配置项 | 说明 | -| --- | --- | -| `API Key` | 你的模型服务密钥 | -| `Base URL` | 模型服务基础地址,默认建议 `https://api.moonshot.cn/v1` | -| `API URL` | 可选。直接指定完整 `chat/completions` 地址,优先级高于 Base URL | -| `Model` | 调用模型名,例如 `moonshot-v1-8k` | +- Java 后端地址:本地默认 `http://localhost:8080` +- 后端访问令牌:如果后端配置了 `APP_SECURITY_TOKEN`,这里填写相同值;否则留空 -## 权限说明 +保存时,扩展会按后端域名申请运行时权限。扩展本地不再保存 AI API Key、模型名或模型接口地址。 -项目当前声明了以下扩展权限: +## 日常使用 -- `tabs`:读取当前活动标签页 URL -- `storage`:保存用户本地配置(API Key / URL / Model) -- `scripting`:向 GitHub 页面注入脚本,用于自动粘贴结果 -- `permissions`:运行时按接口域名申请权限 +1. 打开 `https://github.com///pull/` 或 Compare 页面 +2. 打开扩展,点击“一键生成 PR 描述” +3. Java 后端抓取 Diff 并生成结果 +4. 在弹窗中检查 Markdown +5. 展开 GitHub 的 PR 描述编辑框,点击“一键粘贴到 GitHub 描述框” -默认站点权限: +当前输出结构为: -- `https://github.com/*` -- `https://patch-diff.githubusercontent.com/*` +- `## 📝 描述` +- `## 🛠 改动清单` +- `## 影响范围` -可选站点权限(仅在用户保存配置时按目标接口域名申请): +## Java API -- `https://*/*` -- `http://*/*` +### 健康检查 -这些权限分别用于识别 GitHub 页面、抓取 Diff、回填 PR 描述,以及在你授权后请求你自己配置的 AI 接口。 +```http +GET /api/health +``` -## 项目结构 +示例响应: -```text -. -├── assets/ # 图标等静态资源 -├── .github/workflows/ # GitHub Actions -├── lib/ # AI 请求与配置存储逻辑 -├── popup.tsx # 扩展弹窗(生成 + 设置) -├── package.json # 依赖、脚本、扩展权限 -└── README.md +```json +{ + "status": "ok", + "aiConfigured": true, + "model": "moonshot-v1-8k" +} ``` -## 当前状态 +### 生成 PR 描述 -这个项目目前更像一个可用的 MVP,适合继续打磨成真正的开源工具。它已经能完成“抓 Diff -> 生成 PR 描述 -> 自动回填”这条主链路,但还有一些典型的早期项目特征: +```http +POST /api/pr-descriptions +Content-Type: application/json -- 逻辑主要集中在单文件里,后续可以继续拆分模块 -- prompt 已升级为统一 PR 模版,但还没有自定义模板配置能力 -- Diff 做了长度截断,超大改动场景下摘要质量可能下降 -- API Key 改为用户本地配置,但仍属于前端持有密钥方案,不适合高安全要求场景 +{ + "githubUrl": "https://github.com/owner/repo/pull/123" +} +``` -如果后续准备把它做成更长期维护的项目,更推荐把 AI 请求迁移到后端服务中处理,同时补充更细粒度的配置和测试。 +示例响应: -## Roadmap +```json +{ + "content": "## 📝 描述\n- ..." +} +``` -- [x] 支持从 GitHub PR / Compare 页面抓取 Diff -- [x] 支持调用可配置 AI 接口生成 PR 描述 -- [x] 支持一键粘贴到 GitHub PR 描述框 -- [ ] 支持自定义 Prompt / 模板 -- [ ] 支持中英文输出切换 -- [x] 支持按统一章节生成测试说明、风险说明、回滚说明 -- [ ] 支持超长 Diff 分段总结 -- [ ] 支持本地保存生成历史 -- [ ] 补充更完善的错误提示与可观测性 -- [ ] 增加自动化测试 +### 测试 AI 连接 -## 适合谁 +```http +POST /api/ai/test +``` -如果你经常遇到下面这些情况,这个项目会比较顺手: +## 后端配置 -- 每次提 PR 都要重复写类似结构的描述 -- 团队里没有固定的 PR 描述标准,导致每个人写法差异很大 -- 想让 PR 内容更整洁,但不想花太多时间整理 -- 希望 Reviewer 能更快抓住改动重点,减少来回追问 -- 希望直接在 GitHub 页面中完成“生成 + 粘贴”闭环 -- 正在探索 AI 在研发流程中的轻量提效工具 +| 环境变量 | 默认值 | 说明 | +| --- | --- | --- | +| `AI_API_KEY` | 空 | 必填,模型服务密钥 | +| `AI_BASE_URL` | `https://api.moonshot.cn/v1` | 模型服务基础地址 | +| `AI_API_URL` | 空 | 完整 Chat Completions 地址,设置后优先于 Base URL | +| `AI_MODEL` | `moonshot-v1-8k` | 模型名 | +| `AI_MAX_ATTEMPTS` | `3` | 对限流和过载错误的最大尝试次数 | +| `AI_RETRY_DELAY` | `1200ms` | 线性退避的基础延迟 | +| `AI_REQUEST_TIMEOUT` | `90s` | 模型请求超时 | +| `GITHUB_TOKEN` | 空 | 可选;读取私有仓库或提高 GitHub API 配额 | +| `GITHUB_DIFF_CHARACTER_LIMIT` | `15000` | 发送给模型的最大 Diff 字符数 | +| `APP_SECURITY_TOKEN` | 空 | 可选;保护 Java API 的共享令牌,部署时建议配置 | +| `PORT` | `8080` | 服务端口 | -## 贡献方式 +没有 `GITHUB_TOKEN` 时,后端通过公开 `.diff` 地址读取公开仓库。配置 Token 后,后端改用 GitHub API,因此可以读取该 Token 有权访问的私有仓库。 -欢迎继续把这个项目打磨成真正好用的开源工具。比较适合的贡献方向包括: +## 安全与部署说明 -- 优化生成 Prompt 质量 -- 增加更多 GitHub 页面兼容性 -- 重构前端结构,拆分当前逻辑 -- 增加设置页,支持模板和模型配置 -- 补充测试、文档和错误处理 +- 后端只接受主机名严格为 `github.com` 的 PR / Compare URL,避免将服务变成任意 URL 代理。 +- CORS 默认允许 Chrome 扩展以及本机开发地址。 +- 本地单人使用可以不设置 `APP_SECURITY_TOKEN`;部署到公网时应配置强随机令牌,并在反向代理层启用 HTTPS、限流和访问日志。 +- Java 后端持有 AI Key,也会接触 Diff 内容;团队部署时应由可信环境托管。 -如果你准备接收社区贡献,仓库里现在已经补上了这些基础文件: +## 测试与构建 -- [`LICENSE`](/Users/cc/my-github-ai-helper/LICENSE) -- [`CONTRIBUTING.md`](/Users/cc/my-github-ai-helper/CONTRIBUTING.md) -- [Issue templates](/Users/cc/my-github-ai-helper/.github/ISSUE_TEMPLATE) -- [Pull request template](/Users/cc/my-github-ai-helper/.github/pull_request_template.md) +Java 后端测试: -## 构建与打包 +```bash +npm run backend:test +``` -生产构建: +扩展生产构建: ```bash npm run build ``` -打包扩展: +后端打包: ```bash -npm run package +cd backend +mvn package +java -jar target/ai-pr-helper-backend-0.1.0.jar ``` -仓库中已经存在一个用于提交浏览器商店的 GitHub Actions 工作流: - -- [submit.yml](/Users/cc/my-github-ai-helper/.github/workflows/submit.yml) +扩展打包: -## 技术栈 - -- React 18 -- TypeScript -- Plasmo -- Chrome Extension APIs -- OpenAI 兼容 Chat Completions API +```bash +npm run package +``` -## 已知限制 +## 当前限制与 Roadmap -- 当前仅支持 GitHub PR / Compare 页面 -- 只对页面中可识别到的 PR 描述 textarea 生效 -- Diff 内容目前会截断到前 `15000` 个字符 -- AI API Key 当前直接暴露在浏览器扩展前端环境中 +- [x] Java 后端代理 AI 请求,浏览器不再保存 AI API Key +- [x] 服务端抓取和截断 Git Diff +- [x] 服务端重试限流和过载错误 +- [x] 支持可选后端访问令牌 +- [x] 支持可选 GitHub Token 和私有仓库 +- [ ] 支持超长 Diff 分段总结 +- [ ] 支持自定义 Prompt / 模板 +- [ ] 支持中英文输出切换 +- [ ] 增加生成历史和持久化 +- [ ] 增加服务端指标、限流与链路追踪 -## License +## 贡献 -本项目当前使用 [`MIT License`](/Users/cc/my-github-ai-helper/LICENSE)。 +请参阅 [CONTRIBUTING.md](./CONTRIBUTING.md)。项目使用 [MIT License](./LICENSE)。 diff --git a/assets/workflow-overview.svg b/assets/workflow-overview.svg index 7b74d46..10062f6 100644 --- a/assets/workflow-overview.svg +++ b/assets/workflow-overview.svg @@ -1,6 +1,6 @@ AI PR Helper 工作原理图 - 展示 GitHub 页面、浏览器扩展、AI 服务和一键回填之间的数据流转关系。 + 展示 GitHub 页面、浏览器扩展、Java 后端、AI 服务和一键回填之间的数据流转关系。 @@ -61,7 +61,7 @@ AI PR Helper 工作原理 - 从 GitHub Diff 到结构化 PR 描述,全流程在浏览器侧完成,并支持一键回填 + 浏览器负责交互与回填,Java 后端负责 Diff 抓取、Prompt 和 AI 调用 @@ -97,14 +97,14 @@ 2 - 拼接 .diff 地址并抓取内容 - 必要时截断长文本,降低上下文体积 + 将页面 URL 发送给 Java 后端 + 浏览器不再保存或发送模型 API Key 3 - 组合提示词并请求 AI 总结 - 统一输出 PR 描述结构,减少人工整理成本 + 接收并预览 Markdown 结果 + 后端统一生成结构和错误处理 @@ -116,9 +116,9 @@ - AI 服务 - Chat Completions - 兼容 OpenAI 协议 + Java 后端 + Spring Boot API + 抓 Diff · 调用 AI @@ -137,6 +137,6 @@ - Browser-side workflow overview + Extension + Java backend diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..839bf78 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +target +.env +.DS_Store diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6ef9bc6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +AI_API_KEY=your_api_key_here +AI_BASE_URL=https://api.moonshot.cn/v1 +AI_API_URL= +AI_MODEL=moonshot-v1-8k + +# Optional. Needed when the backend must read private repositories. +GITHUB_TOKEN= + +# Optional. Set the same value in the extension to protect a deployed backend. +APP_SECURITY_TOKEN=replace_with_a_long_random_token + +PORT=8080 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0490b83 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /workspace +COPY pom.xml . +COPY src src +RUN mvn --batch-mode --no-transfer-progress verify + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /workspace/target/ai-pr-helper-backend-*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..028a6e4 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + + dev.jackcc + ai-pr-helper-backend + 0.1.0 + AI PR Helper Backend + Java backend for AI PR Helper + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/AiPrHelperApplication.java b/backend/src/main/java/dev/jackcc/aiprhelper/AiPrHelperApplication.java new file mode 100644 index 0000000..0b74312 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/AiPrHelperApplication.java @@ -0,0 +1,15 @@ +package dev.jackcc.aiprhelper; + +import dev.jackcc.aiprhelper.config.AppProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(AppProperties.class) +public class AiPrHelperApplication { + + public static void main(String[] args) { + SpringApplication.run(AiPrHelperApplication.class, args); + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/api/ApiException.java b/backend/src/main/java/dev/jackcc/aiprhelper/api/ApiException.java new file mode 100644 index 0000000..721bf38 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/api/ApiException.java @@ -0,0 +1,17 @@ +package dev.jackcc.aiprhelper.api; + +import org.springframework.http.HttpStatus; + +public class ApiException extends RuntimeException { + + private final HttpStatus status; + + public ApiException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/api/GlobalExceptionHandler.java b/backend/src/main/java/dev/jackcc/aiprhelper/api/GlobalExceptionHandler.java new file mode 100644 index 0000000..ab79dcd --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/api/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package dev.jackcc.aiprhelper.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ApiException.class) + ResponseEntity> handleApiException(ApiException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(Map.of("message", exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + ResponseEntity> handleValidationException(MethodArgumentNotValidException exception) { + String message = exception.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage() == null ? "请求参数无效" : error.getDefaultMessage()) + .orElse("请求参数无效"); + + return ResponseEntity.badRequest().body(Map.of("message", message)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + ResponseEntity> handleUnreadableRequest() { + return ResponseEntity.badRequest().body(Map.of("message", "请求体不是有效的 JSON")); + } + + @ExceptionHandler(Exception.class) + ResponseEntity> handleUnexpectedException(Exception exception) { + log.error("Unhandled backend error", exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "后端发生未预期错误,请查看服务日志")); + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/api/PrDescriptionController.java b/backend/src/main/java/dev/jackcc/aiprhelper/api/PrDescriptionController.java new file mode 100644 index 0000000..b687ba6 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/api/PrDescriptionController.java @@ -0,0 +1,48 @@ +package dev.jackcc.aiprhelper.api; + +import dev.jackcc.aiprhelper.config.AppProperties; +import dev.jackcc.aiprhelper.service.PrDescriptionService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +public class PrDescriptionController { + + private final PrDescriptionService prDescriptionService; + private final AppProperties properties; + + public PrDescriptionController(PrDescriptionService prDescriptionService, AppProperties properties) { + this.prDescriptionService = prDescriptionService; + this.properties = properties; + } + + @GetMapping("/health") + HealthResponse health() { + return new HealthResponse("ok", properties.getAi().isConfigured(), properties.getAi().getModel()); + } + + @PostMapping("/pr-descriptions") + ContentResponse generate(@Valid @RequestBody GenerateRequest request) { + return new ContentResponse(prDescriptionService.generate(request.githubUrl())); + } + + @PostMapping("/ai/test") + ContentResponse testAiConnection() { + return new ContentResponse(prDescriptionService.testAiConnection()); + } + + public record GenerateRequest(@NotBlank(message = "GitHub 页面地址不能为空") String githubUrl) { + } + + public record ContentResponse(String content) { + } + + public record HealthResponse(String status, boolean aiConfigured, String model) { + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/config/AppProperties.java b/backend/src/main/java/dev/jackcc/aiprhelper/config/AppProperties.java new file mode 100644 index 0000000..1b00972 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/config/AppProperties.java @@ -0,0 +1,180 @@ +package dev.jackcc.aiprhelper.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; +import java.util.List; + +@ConfigurationProperties(prefix = "app") +public class AppProperties { + + private final Ai ai = new Ai(); + private final Github github = new Github(); + private final Cors cors = new Cors(); + private final Security security = new Security(); + + public Ai getAi() { + return ai; + } + + public Github getGithub() { + return github; + } + + public Cors getCors() { + return cors; + } + + public Security getSecurity() { + return security; + } + + public static class Ai { + private String apiKey = ""; + private String apiUrl = ""; + private String baseUrl = "https://api.moonshot.cn/v1"; + private String model = "moonshot-v1-8k"; + private double temperature = 0.3; + private int maxAttempts = 3; + private Duration retryDelay = Duration.ofMillis(1200); + private Duration requestTimeout = Duration.ofSeconds(90); + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getApiUrl() { + return apiUrl; + } + + public void setApiUrl(String apiUrl) { + this.apiUrl = apiUrl; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public double getTemperature() { + return temperature; + } + + public void setTemperature(double temperature) { + this.temperature = temperature; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public void setMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + public Duration getRetryDelay() { + return retryDelay; + } + + public void setRetryDelay(Duration retryDelay) { + this.retryDelay = retryDelay; + } + + public Duration getRequestTimeout() { + return requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public boolean isConfigured() { + return hasText(apiKey) && hasText(model) && (hasText(apiUrl) || hasText(baseUrl)); + } + } + + public static class Github { + private String token = ""; + private int diffCharacterLimit = 15000; + private Duration requestTimeout = Duration.ofSeconds(30); + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public int getDiffCharacterLimit() { + return diffCharacterLimit; + } + + public void setDiffCharacterLimit(int diffCharacterLimit) { + this.diffCharacterLimit = diffCharacterLimit; + } + + public Duration getRequestTimeout() { + return requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public boolean hasToken() { + return hasText(token); + } + } + + public static class Cors { + private List allowedOriginPatterns = List.of( + "chrome-extension://*", + "http://localhost:*", + "http://127.0.0.1:*" + ); + + public List getAllowedOriginPatterns() { + return allowedOriginPatterns; + } + + public void setAllowedOriginPatterns(List allowedOriginPatterns) { + this.allowedOriginPatterns = allowedOriginPatterns; + } + } + + public static class Security { + private String token = ""; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isEnabled() { + return hasText(token); + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/config/HttpClientConfig.java b/backend/src/main/java/dev/jackcc/aiprhelper/config/HttpClientConfig.java new file mode 100644 index 0000000..a8d7e00 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/config/HttpClientConfig.java @@ -0,0 +1,19 @@ +package dev.jackcc.aiprhelper.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class HttpClientConfig { + + @Bean + HttpClient httpClient() { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/config/WebConfig.java b/backend/src/main/java/dev/jackcc/aiprhelper/config/WebConfig.java new file mode 100644 index 0000000..b5808df --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/config/WebConfig.java @@ -0,0 +1,24 @@ +package dev.jackcc.aiprhelper.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AppProperties properties; + + public WebConfig(AppProperties properties) { + this.properties = properties; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns(properties.getCors().getAllowedOriginPatterns().toArray(String[]::new)) + .allowedMethods("GET", "POST", "OPTIONS") + .allowedHeaders("Content-Type", "X-Backend-Token") + .maxAge(3600); + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/security/BackendTokenFilter.java b/backend/src/main/java/dev/jackcc/aiprhelper/security/BackendTokenFilter.java new file mode 100644 index 0000000..2f5e237 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/security/BackendTokenFilter.java @@ -0,0 +1,68 @@ +package dev.jackcc.aiprhelper.security; + +import dev.jackcc.aiprhelper.config.AppProperties; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; + +@Component +public class BackendTokenFilter extends OncePerRequestFilter { + + public static final String TOKEN_HEADER = "X-Backend-Token"; + + private final AppProperties properties; + private final ObjectMapper objectMapper; + + public BackendTokenFilter(AppProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/") + || !properties.getSecurity().isEnabled() + || "OPTIONS".equalsIgnoreCase(request.getMethod()); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String expectedToken = properties.getSecurity().getToken().trim(); + String suppliedToken = request.getHeader(TOKEN_HEADER); + + if (!tokensMatch(expectedToken, suppliedToken)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), Map.of("message", "后端访问令牌无效或未提供")); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean tokensMatch(String expected, String supplied) { + if (supplied == null) { + return false; + } + + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + supplied.trim().getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/service/AiClient.java b/backend/src/main/java/dev/jackcc/aiprhelper/service/AiClient.java new file mode 100644 index 0000000..2f4d3ae --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/service/AiClient.java @@ -0,0 +1,201 @@ +package dev.jackcc.aiprhelper.service; + +import dev.jackcc.aiprhelper.api.ApiException; +import dev.jackcc.aiprhelper.config.AppProperties; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +public class AiClient { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final AppProperties properties; + + public AiClient(HttpClient httpClient, ObjectMapper objectMapper, AppProperties properties) { + this.httpClient = httpClient; + this.objectMapper = objectMapper; + this.properties = properties; + } + + public String chat(List messages) { + AppProperties.Ai ai = properties.getAi(); + ensureConfigured(ai); + URI apiUri = resolveApiUri(ai); + String requestBody = serializeRequest(ai, messages); + int maxAttempts = Math.max(1, ai.getMaxAttempts()); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + HttpRequest request = HttpRequest.newBuilder(apiUri) + .timeout(ai.getRequestTimeout()) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + ai.getApiKey().trim()) + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .build(); + + final HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new ApiException(HttpStatus.BAD_GATEWAY, "AI API 请求被中断"); + } catch (IOException exception) { + throw new ApiException( + HttpStatus.BAD_GATEWAY, + "AI API 网络异常,请检查后端是否能访问 " + originLabel(apiUri) + ); + } + + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return readContent(response.body()); + } + + String errorMessage = readErrorMessage(response.statusCode(), response.body()); + boolean retryable = isRetryableFailure(response.statusCode(), errorMessage); + + if (retryable && attempt < maxAttempts) { + sleep(ai.getRetryDelay().multipliedBy(attempt)); + continue; + } + + if (retryable) { + throw new ApiException( + HttpStatus.SERVICE_UNAVAILABLE, + "AI 服务当前繁忙,已自动重试 " + maxAttempts + " 次仍失败。" + + "请稍后再试,或修改后端模型配置。原始错误:" + errorMessage + ); + } + + throw new ApiException(HttpStatus.BAD_GATEWAY, "AI API 请求失败:" + errorMessage); + } + + throw new ApiException(HttpStatus.BAD_GATEWAY, "AI API 请求失败:未获得有效响应"); + } + + URI resolveApiUri(AppProperties.Ai ai) { + String value = hasText(ai.getApiUrl()) + ? ai.getApiUrl().trim() + : stripTrailingSlashes(ai.getBaseUrl().trim()) + "/chat/completions"; + + try { + URI uri = new URI(value); + if (uri.getScheme() == null + || !(uri.getScheme().equalsIgnoreCase("https") || uri.getScheme().equalsIgnoreCase("http")) + || uri.getHost() == null + || uri.getUserInfo() != null) { + throw new URISyntaxException(value, "unsupported API URL"); + } + return uri; + } catch (URISyntaxException exception) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "后端 AI API 地址配置无效"); + } + } + + boolean isRetryableFailure(int status, String errorMessage) { + String normalized = errorMessage.toLowerCase(Locale.ROOT); + return status == 429 + || status == 503 + || normalized.contains("overloaded") + || normalized.contains("rate limit") + || normalized.contains("too many requests") + || normalized.contains("busy"); + } + + private String serializeRequest(AppProperties.Ai ai, List messages) { + Map payload = Map.of( + "model", ai.getModel().trim(), + "messages", messages, + "temperature", ai.getTemperature() + ); + + try { + return objectMapper.writeValueAsString(payload); + } catch (JacksonException exception) { + throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "无法构造 AI API 请求"); + } + } + + private String readContent(String responseBody) { + try { + JsonNode root = objectMapper.readTree(responseBody); + String content = root.path("choices").path(0).path("message").path("content").stringValue(""); + + if (content.isBlank()) { + throw new ApiException(HttpStatus.BAD_GATEWAY, "AI API 已返回响应,但没有拿到生成内容"); + } + + return content; + } catch (JacksonException exception) { + throw new ApiException(HttpStatus.BAD_GATEWAY, "AI API 返回了无法解析的响应"); + } + } + + private String readErrorMessage(int status, String responseBody) { + if (responseBody != null && !responseBody.isBlank()) { + try { + JsonNode root = objectMapper.readTree(responseBody); + String nestedMessage = root.path("error").path("message").stringValue(""); + if (!nestedMessage.isBlank()) { + return nestedMessage; + } + + String directMessage = root.path("message").stringValue(""); + if (!directMessage.isBlank()) { + return directMessage; + } + } catch (JacksonException ignored) { + // Fall back to the status code without reflecting an arbitrary HTML response. + } + } + + return Integer.toString(status); + } + + private void ensureConfigured(AppProperties.Ai ai) { + if (!ai.isConfigured()) { + throw new ApiException( + HttpStatus.SERVICE_UNAVAILABLE, + "Java 后端尚未配置 AI_API_KEY、模型和接口地址" + ); + } + } + + private void sleep(Duration duration) { + try { + Thread.sleep(Math.max(0, duration.toMillis())); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "等待重试时请求被中断"); + } + } + + private String originLabel(URI uri) { + return uri.getScheme() + "://" + uri.getAuthority(); + } + + private String stripTrailingSlashes(String value) { + return value.replaceFirst("/+$", ""); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public record ChatMessage(String role, String content) { + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/service/GitHubDiffService.java b/backend/src/main/java/dev/jackcc/aiprhelper/service/GitHubDiffService.java new file mode 100644 index 0000000..7901173 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/service/GitHubDiffService.java @@ -0,0 +1,165 @@ +package dev.jackcc.aiprhelper.service; + +import dev.jackcc.aiprhelper.api.ApiException; +import dev.jackcc.aiprhelper.config.AppProperties; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class GitHubDiffService { + + private static final Pattern PULL_PATH = Pattern.compile("^/([^/]+)/([^/]+)/pull/(\\d+)(?:/.*)?$"); + private static final Pattern COMPARE_PATH = Pattern.compile("^/([^/]+)/([^/]+)/compare/(.+)$"); + + private final HttpClient httpClient; + private final AppProperties properties; + + public GitHubDiffService(HttpClient httpClient, AppProperties properties) { + this.httpClient = httpClient; + this.properties = properties; + } + + public String fetchDiff(String githubUrl) { + URI diffUri = resolveFetchUri(githubUrl); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(diffUri) + .timeout(properties.getGithub().getRequestTimeout()) + .header("Accept", "text/plain, application/vnd.github.diff") + .header("User-Agent", "AI-PR-Helper/0.1") + .GET(); + + if (properties.getGithub().hasToken()) { + requestBuilder.header("Authorization", "Bearer " + properties.getGithub().getToken().trim()); + requestBuilder.header("X-GitHub-Api-Version", "2022-11-28"); + } + + HttpRequest request = requestBuilder.build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new ApiException( + HttpStatus.BAD_GATEWAY, + "抓取 Diff 失败,GitHub 返回了 " + response.statusCode() + ); + } + + if (response.body() == null || response.body().isBlank()) { + throw new ApiException( + HttpStatus.BAD_GATEWAY, + "抓取到的 Diff 为空,请确认 PR 或 Compare 页面可以公开访问" + ); + } + + return truncate(response.body()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new ApiException(HttpStatus.BAD_GATEWAY, "抓取 Diff 的请求被中断"); + } catch (IOException exception) { + throw new ApiException(HttpStatus.BAD_GATEWAY, "抓取 Diff 时网络异常,请检查后端是否能访问 GitHub"); + } + } + + URI resolveDiffUri(String rawUrl) { + DiffTarget target = parseTarget(rawUrl); + if (target.kind() == DiffKind.PULL) { + return URI.create("https://github.com/" + + target.owner() + "/" + + target.repository() + "/pull/" + + target.reference() + ".diff"); + } + + return URI.create("https://github.com/" + + target.owner() + "/" + + target.repository() + "/compare/" + + target.reference() + ".diff"); + } + + URI resolveFetchUri(String rawUrl) { + if (!properties.getGithub().hasToken()) { + return resolveDiffUri(rawUrl); + } + + DiffTarget target = parseTarget(rawUrl); + String resource = target.kind() == DiffKind.PULL + ? "pulls/" + target.reference() + : "compare/" + target.reference(); + + return URI.create("https://api.github.com/repos/" + + target.owner() + "/" + + target.repository() + "/" + + resource); + } + + private DiffTarget parseTarget(String rawUrl) { + final URI sourceUri; + + try { + sourceUri = new URI(rawUrl.trim()); + } catch (URISyntaxException | NullPointerException exception) { + throw unsupportedPage(); + } + + String scheme = sourceUri.getScheme(); + String host = sourceUri.getHost(); + String rawPath = sourceUri.getRawPath(); + + if (scheme == null + || !(scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http")) + || host == null + || !host.toLowerCase(Locale.ROOT).equals("github.com") + || sourceUri.getUserInfo() != null + || rawPath == null) { + throw unsupportedPage(); + } + + Matcher pullMatcher = PULL_PATH.matcher(rawPath); + if (pullMatcher.matches()) { + return new DiffTarget( + pullMatcher.group(1), + pullMatcher.group(2), + pullMatcher.group(3), + DiffKind.PULL + ); + } + + Matcher compareMatcher = COMPARE_PATH.matcher(rawPath); + if (compareMatcher.matches() && !compareMatcher.group(3).isBlank()) { + return new DiffTarget( + compareMatcher.group(1), + compareMatcher.group(2), + compareMatcher.group(3), + DiffKind.COMPARE + ); + } + + throw unsupportedPage(); + } + + String truncate(String diff) { + int limit = Math.max(1, properties.getGithub().getDiffCharacterLimit()); + return diff.length() <= limit ? diff : diff.substring(0, limit); + } + + private ApiException unsupportedPage() { + return new ApiException(HttpStatus.BAD_REQUEST, "请在 GitHub Pull Request 或 Compare 页面使用"); + } + + private enum DiffKind { + PULL, + COMPARE + } + + private record DiffTarget(String owner, String repository, String reference, DiffKind kind) { + } +} diff --git a/backend/src/main/java/dev/jackcc/aiprhelper/service/PrDescriptionService.java b/backend/src/main/java/dev/jackcc/aiprhelper/service/PrDescriptionService.java new file mode 100644 index 0000000..3e0a269 --- /dev/null +++ b/backend/src/main/java/dev/jackcc/aiprhelper/service/PrDescriptionService.java @@ -0,0 +1,59 @@ +package dev.jackcc.aiprhelper.service; + +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PrDescriptionService { + + private static final String SYSTEM_PROMPT = """ + 你是资深工程师,负责根据 Git Diff 生成统一、可直接粘贴到 GitHub 的 PR 描述。 + + 请严格遵守以下要求: + 1. 仅依据提供的 Diff 输出,不要臆测未出现的业务背景、测试结果、风险或需求来源。 + 2. 输出语言使用简体中文,格式使用 Markdown。 + 3. 必须严格按照下面的模版输出,保留所有一级标题,不能新增一级标题,不能输出标题以外的开场白或结尾。 + 4. 每个列表项尽量简洁、明确,优先描述真实改动和评审重点。 + 5. 如果某部分无法从 Diff 明确判断,请写“未从 Diff 中明确看出”。 + 6. 不要使用代码块包裹整个结果。 + + 输出模版: + ## 📝 描述 + - 用 1-2 条概括这次改动解决了什么问题、核心变化是什么。 + + ## 🛠 改动清单 + - 按功能点或模块列出 2-4 条关键改动。 + + ## 影响范围 + - 说明受影响的页面、模块、接口、配置或流程。 + + 额外要求: + - 如果 Diff 明显是前端改动,优先指出交互、文案、样式、状态流转变化。 + - 如果 Diff 明显是后端或基础设施改动,优先指出接口、数据流、配置、兼容性变化。 + - 如果改动很小,也必须完整输出整套模版。 + """.trim(); + + private final GitHubDiffService gitHubDiffService; + private final AiClient aiClient; + + public PrDescriptionService(GitHubDiffService gitHubDiffService, AiClient aiClient) { + this.gitHubDiffService = gitHubDiffService; + this.aiClient = aiClient; + } + + public String generate(String githubUrl) { + String diff = gitHubDiffService.fetchDiff(githubUrl); + return aiClient.chat(List.of( + new AiClient.ChatMessage("system", SYSTEM_PROMPT), + new AiClient.ChatMessage("user", "请基于下面的 Git Diff 生成 PR 描述。\n\nGit Diff:\n" + diff) + )); + } + + public String testAiConnection() { + return aiClient.chat(List.of( + new AiClient.ChatMessage("system", "你是一个连接测试助手。"), + new AiClient.ChatMessage("user", "请只返回“连接成功”。") + )); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..8cfd488 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,23 @@ +server: + port: ${PORT:8080} + +spring: + application: + name: ai-pr-helper-backend + +app: + ai: + api-key: ${AI_API_KEY:} + api-url: ${AI_API_URL:} + base-url: ${AI_BASE_URL:https://api.moonshot.cn/v1} + model: ${AI_MODEL:moonshot-v1-8k} + temperature: ${AI_TEMPERATURE:0.3} + max-attempts: ${AI_MAX_ATTEMPTS:3} + retry-delay: ${AI_RETRY_DELAY:1200ms} + request-timeout: ${AI_REQUEST_TIMEOUT:90s} + github: + token: ${GITHUB_TOKEN:} + diff-character-limit: ${GITHUB_DIFF_CHARACTER_LIMIT:15000} + request-timeout: ${GITHUB_REQUEST_TIMEOUT:30s} + security: + token: ${APP_SECURITY_TOKEN:} diff --git a/backend/src/test/java/dev/jackcc/aiprhelper/AiPrHelperApplicationTests.java b/backend/src/test/java/dev/jackcc/aiprhelper/AiPrHelperApplicationTests.java new file mode 100644 index 0000000..d6ebc4c --- /dev/null +++ b/backend/src/test/java/dev/jackcc/aiprhelper/AiPrHelperApplicationTests.java @@ -0,0 +1,12 @@ +package dev.jackcc.aiprhelper; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AiPrHelperApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/backend/src/test/java/dev/jackcc/aiprhelper/service/AiClientTest.java b/backend/src/test/java/dev/jackcc/aiprhelper/service/AiClientTest.java new file mode 100644 index 0000000..d3ee2fe --- /dev/null +++ b/backend/src/test/java/dev/jackcc/aiprhelper/service/AiClientTest.java @@ -0,0 +1,44 @@ +package dev.jackcc.aiprhelper.service; + +import dev.jackcc.aiprhelper.config.AppProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpClient; +import tools.jackson.databind.ObjectMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +class AiClientTest { + + private AppProperties properties; + private AiClient client; + + @BeforeEach + void setUp() { + properties = new AppProperties(); + client = new AiClient(HttpClient.newHttpClient(), new ObjectMapper(), properties); + } + + @Test + void resolvesExplicitApiUrlBeforeBaseUrl() { + properties.getAi().setApiUrl("https://example.com/custom/chat"); + assertThat(client.resolveApiUri(properties.getAi()).toString()) + .isEqualTo("https://example.com/custom/chat"); + } + + @Test + void appendsChatCompletionsToBaseUrl() { + properties.getAi().setApiUrl(""); + properties.getAi().setBaseUrl("https://example.com/v1/"); + assertThat(client.resolveApiUri(properties.getAi()).toString()) + .isEqualTo("https://example.com/v1/chat/completions"); + } + + @Test + void detectsRateLimitAndOverloadFailuresAsRetryable() { + assertThat(client.isRetryableFailure(429, "rate limit reached")).isTrue(); + assertThat(client.isRetryableFailure(500, "service overloaded")).isTrue(); + assertThat(client.isRetryableFailure(400, "invalid request")).isFalse(); + } +} diff --git a/backend/src/test/java/dev/jackcc/aiprhelper/service/GitHubDiffServiceTest.java b/backend/src/test/java/dev/jackcc/aiprhelper/service/GitHubDiffServiceTest.java new file mode 100644 index 0000000..7261d4f --- /dev/null +++ b/backend/src/test/java/dev/jackcc/aiprhelper/service/GitHubDiffServiceTest.java @@ -0,0 +1,55 @@ +package dev.jackcc.aiprhelper.service; + +import dev.jackcc.aiprhelper.api.ApiException; +import dev.jackcc.aiprhelper.config.AppProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GitHubDiffServiceTest { + + private AppProperties properties; + private GitHubDiffService service; + + @BeforeEach + void setUp() { + properties = new AppProperties(); + service = new GitHubDiffService(HttpClient.newHttpClient(), properties); + } + + @Test + void resolvesPullRequestPagesAndIgnoresSubPages() { + assertThat(service.resolveDiffUri("https://github.com/openai/codex/pull/123/files").toString()) + .isEqualTo("https://github.com/openai/codex/pull/123.diff"); + } + + @Test + void resolvesComparePagesAndDropsTheQueryString() { + assertThat(service.resolveDiffUri("https://github.com/openai/codex/compare/main...feature?expand=1").toString()) + .isEqualTo("https://github.com/openai/codex/compare/main...feature.diff"); + } + + @Test + void rejectsNonGitHubHostsToPreventArbitraryServerSideRequests() { + assertThatThrownBy(() -> service.resolveDiffUri("https://example.com/openai/codex/pull/123")) + .isInstanceOf(ApiException.class) + .hasMessage("请在 GitHub Pull Request 或 Compare 页面使用"); + } + + @Test + void usesTheGitHubApiWhenATokenIsConfigured() { + properties.getGithub().setToken("test-token"); + assertThat(service.resolveFetchUri("https://github.com/openai/codex/pull/123").toString()) + .isEqualTo("https://api.github.com/repos/openai/codex/pulls/123"); + } + + @Test + void truncatesDiffAtTheConfiguredLimit() { + properties.getGithub().setDiffCharacterLimit(5); + assertThat(service.truncate("123456789")).isEqualTo("12345"); + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..14164dc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,10 @@ +services: + backend: + build: + context: ./backend + env_file: + - path: ./backend/.env + required: false + ports: + - "${PORT:-8080}:${PORT:-8080}" + restart: unless-stopped diff --git a/lib/ai.ts b/lib/ai.ts deleted file mode 100644 index 339f33a..0000000 --- a/lib/ai.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { type AiSettings, resolveAiApiUrl } from "./settings" - -const AI_REQUEST_MAX_ATTEMPTS = 3 -const AI_RETRY_DELAY_MS = 1200 -const DIFF_CHAR_LIMIT = 15000 - -type ChatMessage = { - role: "system" | "user" | "assistant" - content: string -} - -export type DiffResult = - | { ok: true; content: string } - | { ok: false; message: string } - -export type AIResult = - | { ok: true; content: string } - | { ok: false; message: string } - -const prDescriptionSystemPrompt = ` -你是资深工程师,负责根据 Git Diff 生成统一、可直接粘贴到 GitHub 的 PR 描述。 - -请严格遵守以下要求: -1. 仅依据提供的 Diff 输出,不要臆测未出现的业务背景、测试结果、风险或需求来源。 -2. 输出语言使用简体中文,格式使用 Markdown。 -3. 必须严格按照下面的模版输出,保留所有一级标题,不能新增一级标题,不能输出标题以外的开场白或结尾。 -4. 每个列表项尽量简洁、明确,优先描述真实改动和评审重点。 -5. 如果某部分无法从 Diff 明确判断,请写“未从 Diff 中明确看出”。 -6. 不要使用代码块包裹整个结果。 - -输出模版: -## 📝 描述 -- 用 1-2 条概括这次改动解决了什么问题、核心变化是什么。 - -## 🛠 改动清单 -- 按功能点或模块列出 2-4 条关键改动。 - -## 影响范围 -- 说明受影响的页面、模块、接口、配置或流程。 - -额外要求: -- 如果 Diff 明显是前端改动,优先指出交互、文案、样式、状态流转变化。 -- 如果 Diff 明显是后端或基础设施改动,优先指出接口、数据流、配置、兼容性变化。 -- 如果改动很小,也必须完整输出整套模版。 -`.trim() - -const buildPrDescriptionUserPrompt = (diffContent: string) => - `请基于下面的 Git Diff 生成 PR 描述。\n\nGit Diff:\n${diffContent}` - -export const resolveGitHubDiffUrl = (rawUrl: string) => { - try { - const parsedUrl = new URL(rawUrl) - const pullMatch = parsedUrl.pathname.match( - /^\/([^/]+)\/([^/]+)\/pull\/(\d+)/ - ) - - if (pullMatch) { - return `${parsedUrl.origin}/${pullMatch[1]}/${pullMatch[2]}/pull/${pullMatch[3]}.diff` - } - - if (parsedUrl.pathname.includes("/compare/")) { - return `${parsedUrl.origin}${parsedUrl.pathname}.diff` - } - - return null - } catch (error) { - console.error("URL parse failed:", error) - return null - } -} - -export const fetchDiff = async (url: string): Promise => { - const diffUrl = resolveGitHubDiffUrl(url) - - if (!diffUrl) { - return { - ok: false, - message: "请在 GitHub Pull Request 或 Compare 页面使用" - } - } - - try { - const response = await fetch(diffUrl) - - if (!response.ok) { - return { - ok: false, - message: `抓取 Diff 失败,GitHub 返回了 ${response.status} ${response.statusText}` - } - } - - const text = await response.text() - - if (!text.trim()) { - return { - ok: false, - message: "抓取到的 Diff 为空,请确认当前页面是可访问的 GitHub PR 或 Compare 页面" - } - } - - return { ok: true, content: text.slice(0, DIFF_CHAR_LIMIT) } - } catch (error) { - console.error("Fetch diff failed:", error) - return { - ok: false, - message: "抓取 Diff 时网络异常,请检查当前网络是否能访问 GitHub" - } - } -} - -const getApiErrorMessage = async (response: Response) => { - try { - const data = await response.json() - return ( - data?.error?.message || - data?.message || - `${response.status} ${response.statusText}` - ) - } catch { - return `${response.status} ${response.statusText}` - } -} - -const sleep = (ms: number) => - new Promise((resolve) => { - window.setTimeout(resolve, ms) - }) - -const isRetryableApiFailure = (status: number, errorMessage: string) => { - const normalizedMessage = errorMessage.toLowerCase() - - return ( - status === 429 || - status === 503 || - normalizedMessage.includes("overloaded") || - normalizedMessage.includes("rate limit") || - normalizedMessage.includes("too many requests") || - normalizedMessage.includes("busy") - ) -} - -const getApiOriginLabel = (settings: AiSettings) => { - try { - return new URL(resolveAiApiUrl(settings)).origin - } catch { - return resolveAiApiUrl(settings) - } -} - -const requestAi = async ( - settings: AiSettings, - messages: ChatMessage[] -): Promise => { - try { - const apiUrl = resolveAiApiUrl(settings) - - for ( - let attemptIndex = 0; - attemptIndex < AI_REQUEST_MAX_ATTEMPTS; - attemptIndex += 1 - ) { - const response = await fetch(apiUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${settings.apiKey.trim()}` - }, - body: JSON.stringify({ - model: settings.model.trim(), - messages, - temperature: 0.3 - }) - }) - - if (!response.ok) { - const errorMessage = await getApiErrorMessage(response) - const retryable = isRetryableApiFailure(response.status, errorMessage) - const hasNextAttempt = attemptIndex < AI_REQUEST_MAX_ATTEMPTS - 1 - - if (retryable && hasNextAttempt) { - await sleep(AI_RETRY_DELAY_MS * (attemptIndex + 1)) - continue - } - - if (retryable) { - return { - ok: false, - message: - `AI 服务当前繁忙,已自动重试 ${AI_REQUEST_MAX_ATTEMPTS} 次仍失败。` + - `请稍后再试,或切换模型/接口。原始错误:${errorMessage}` - } - } - - return { - ok: false, - message: `AI API 请求失败:${errorMessage}` - } - } - - const data = await response.json() - const content = data?.choices?.[0]?.message?.content - - if (!content) { - return { - ok: false, - message: "AI API 已返回响应,但没有拿到生成内容" - } - } - - return { ok: true, content } - } - - return { - ok: false, - message: "AI API 请求失败:未获得有效响应" - } - } catch (error) { - console.error("AI API request failed:", error) - return { - ok: false, - message: `AI API 网络异常,请检查当前网络是否能访问 ${getApiOriginLabel(settings)}` - } - } -} - -export const askAi = async ( - diffContent: string, - settings: AiSettings -): Promise => - requestAi(settings, [ - { - role: "system", - content: prDescriptionSystemPrompt - }, - { - role: "user", - content: buildPrDescriptionUserPrompt(diffContent) - } - ]) - -export const testAiConnection = async ( - settings: AiSettings -): Promise => - requestAi(settings, [ - { - role: "system", - content: "你是一个连接测试助手。" - }, - { - role: "user", - content: "请只返回“连接成功”。" - } - ]) diff --git a/lib/backend.ts b/lib/backend.ts new file mode 100644 index 0000000..e721c8f --- /dev/null +++ b/lib/backend.ts @@ -0,0 +1,82 @@ +import { resolveBackendUrl, type BackendSettings } from "./settings" + +export type BackendResult = + | { ok: true; content: string } + | { ok: false; message: string } + +const buildHeaders = (settings: BackendSettings) => { + const headers: Record = { + "Content-Type": "application/json" + } + + if (settings.accessToken.trim()) { + headers["X-Backend-Token"] = settings.accessToken.trim() + } + + return headers +} + +const getErrorMessage = async (response: Response) => { + try { + const data = await response.json() + return data?.message || `${response.status} ${response.statusText}` + } catch { + return `${response.status} ${response.statusText}` + } +} + +const requestBackend = async ( + settings: BackendSettings, + path: string, + init: RequestInit +): Promise => { + const url = resolveBackendUrl(settings, path) + + try { + const response = await fetch(url, { + ...init, + headers: buildHeaders(settings) + }) + + if (!response.ok) { + return { + ok: false, + message: `Java 后端请求失败:${await getErrorMessage(response)}` + } + } + + const data = await response.json() + const content = data?.content + + if (!content || typeof content !== "string") { + return { + ok: false, + message: "Java 后端已返回响应,但没有拿到生成内容" + } + } + + return { ok: true, content } + } catch (error) { + console.error("Backend request failed:", error) + return { + ok: false, + message: `无法连接 Java 后端,请确认服务已启动:${url}` + } + } +} + +export const generatePrDescription = async ( + githubUrl: string, + settings: BackendSettings +): Promise => + requestBackend(settings, "/api/pr-descriptions", { + method: "POST", + body: JSON.stringify({ githubUrl }) + }) + +export const testBackendConnection = async ( + settings: BackendSettings +): Promise => + requestBackend(settings, "/api/ai/test", { + method: "POST" + }) diff --git a/lib/settings.ts b/lib/settings.ts index 11f4d3e..c8cd230 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -1,91 +1,69 @@ -export type AiSettings = { - apiKey: string - apiUrl: string +export type BackendSettings = { baseUrl: string - model: string + accessToken: string } -export const DEFAULT_AI_BASE_URL = "https://api.moonshot.cn/v1" -export const DEFAULT_AI_MODEL = "moonshot-v1-8k" +export const DEFAULT_BACKEND_BASE_URL = "http://localhost:8080" -const AI_SETTINGS_STORAGE_KEY = "ai_pr_helper_settings_v1" +const BACKEND_SETTINGS_STORAGE_KEY = "ai_pr_helper_backend_settings_v1" -export const createDefaultAiSettings = (): AiSettings => ({ - apiKey: "", - apiUrl: "", - baseUrl: DEFAULT_AI_BASE_URL, - model: DEFAULT_AI_MODEL +export const createDefaultBackendSettings = (): BackendSettings => ({ + baseUrl: DEFAULT_BACKEND_BASE_URL, + accessToken: "" }) -const normalizeUrl = (value: string) => value.trim().replace(/\/+$/, "") - const sanitizeString = (value: unknown) => typeof value === "string" ? value.trim() : "" -export const sanitizeAiSettings = (settings: AiSettings): AiSettings => ({ - apiKey: sanitizeString(settings.apiKey), - apiUrl: sanitizeString(settings.apiUrl), - baseUrl: sanitizeString(settings.baseUrl), - model: sanitizeString(settings.model) +const normalizeUrl = (value: string) => value.trim().replace(/\/+$/, "") + +export const sanitizeBackendSettings = ( + settings: BackendSettings +): BackendSettings => ({ + baseUrl: normalizeUrl(sanitizeString(settings.baseUrl)), + accessToken: sanitizeString(settings.accessToken) }) -export const hasRequiredAiSettings = (settings: AiSettings) => { - const sanitized = sanitizeAiSettings(settings) - return Boolean( - sanitized.apiKey && - sanitized.model && - (sanitized.apiUrl || sanitized.baseUrl) - ) +export const hasRequiredBackendSettings = (settings: BackendSettings) => + Boolean(sanitizeBackendSettings(settings).baseUrl) + +export const resolveBackendUrl = (settings: BackendSettings, path: string) => { + const baseUrl = sanitizeBackendSettings(settings).baseUrl + const normalizedPath = path.startsWith("/") ? path : `/${path}` + return `${baseUrl}${normalizedPath}` } -export const resolveAiApiUrl = (settings: AiSettings) => { - const sanitized = sanitizeAiSettings(settings) +export const getBackendOriginPattern = (settings: BackendSettings) => { + const backendUrl = new URL(resolveBackendUrl(settings, "/api/health")) - if (sanitized.apiUrl) { - return sanitized.apiUrl + if (backendUrl.protocol !== "http:" && backendUrl.protocol !== "https:") { + throw new Error("unsupported backend protocol") } - return `${normalizeUrl(sanitized.baseUrl)}/chat/completions` -} - -export const getApiOriginPattern = (settings: AiSettings) => { - const apiUrl = resolveAiApiUrl(settings) - const origin = new URL(apiUrl).origin - return `${origin}/*` + // Chrome match patterns do not include ports; this grants the selected host. + return `${backendUrl.protocol}//${backendUrl.hostname}/*` } -export const loadAiSettings = async (): Promise => { - const result = await chrome.storage.local.get(AI_SETTINGS_STORAGE_KEY) - const saved = result?.[AI_SETTINGS_STORAGE_KEY] +export const loadBackendSettings = async (): Promise => { + const result = await chrome.storage.local.get(BACKEND_SETTINGS_STORAGE_KEY) + const saved = result?.[BACKEND_SETTINGS_STORAGE_KEY] if (!saved || typeof saved !== "object") { - return createDefaultAiSettings() + return createDefaultBackendSettings() } - return sanitizeAiSettings({ - ...createDefaultAiSettings(), - ...(saved as Partial) + return sanitizeBackendSettings({ + ...createDefaultBackendSettings(), + ...(saved as Partial) }) } -export const saveAiSettings = async (settings: AiSettings) => { - const sanitized = sanitizeAiSettings(settings) - +export const saveBackendSettings = async (settings: BackendSettings) => { await chrome.storage.local.set({ - [AI_SETTINGS_STORAGE_KEY]: sanitized + [BACKEND_SETTINGS_STORAGE_KEY]: sanitizeBackendSettings(settings) }) } -export const clearAiSettings = async () => { - await chrome.storage.local.remove(AI_SETTINGS_STORAGE_KEY) -} - -export const maskApiKey = (apiKey: string) => { - const trimmed = apiKey.trim() - - if (trimmed.length <= 8) { - return trimmed ? "********" : "未设置" - } - - return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}` +export const clearBackendSettings = async () => { + await chrome.storage.local.remove(BACKEND_SETTINGS_STORAGE_KEY) } diff --git a/package.json b/package.json index 2e6b81f..cc09aa1 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,14 @@ "name": "my-github-ai-helper", "displayName": "My github ai helper", "version": "0.0.1", - "description": "A browser extension that generates GitHub PR descriptions from diffs using configurable AI APIs.", + "description": "A browser extension backed by a Java service that generates GitHub PR descriptions from diffs.", "author": "JackCC ", "scripts": { "dev": "plasmo dev", "build": "plasmo build", - "package": "plasmo package" + "package": "plasmo package", + "backend:dev": "cd backend && mvn spring-boot:run", + "backend:test": "cd backend && mvn test" }, "dependencies": { "plasmo": "0.90.5", diff --git a/popup.tsx b/popup.tsx index cf45b41..e8a9154 100644 --- a/popup.tsx +++ b/popup.tsx @@ -1,17 +1,16 @@ import { useEffect, useState } from "react" -import { askAi, fetchDiff, testAiConnection } from "./lib/ai" +import { generatePrDescription, testBackendConnection } from "./lib/backend" import { - clearAiSettings, - createDefaultAiSettings, - getApiOriginPattern, - hasRequiredAiSettings, - loadAiSettings, - maskApiKey, - resolveAiApiUrl, - sanitizeAiSettings, - saveAiSettings, - type AiSettings + clearBackendSettings, + createDefaultBackendSettings, + getBackendOriginPattern, + hasRequiredBackendSettings, + loadBackendSettings, + resolveBackendUrl, + sanitizeBackendSettings, + saveBackendSettings, + type BackendSettings } from "./lib/settings" type ViewMode = "generate" | "settings" @@ -36,9 +35,9 @@ const getPermissionStatusText = (status: PermissionStatus) => { } } -const getApiTargetLabel = (settings: AiSettings) => { +const getBackendTargetLabel = (settings: BackendSettings) => { try { - return new URL(resolveAiApiUrl(settings)).origin + return new URL(resolveBackendUrl(settings, "/api/pr-descriptions")).origin } catch { return "未配置" } @@ -116,7 +115,9 @@ const getTabButtonStyle = (active: boolean) => ({ function IndexPopup() { const [activeView, setActiveView] = useState("generate") - const [settings, setSettings] = useState(createDefaultAiSettings()) + const [settings, setSettings] = useState( + createDefaultBackendSettings() + ) const [settingsLoaded, setSettingsLoaded] = useState(false) const [permissionStatus, setPermissionStatus] = useState("checking") @@ -129,8 +130,8 @@ function IndexPopup() { const [testing, setTesting] = useState(false) const [notice, setNotice] = useState(null) - const updatePermissionStatus = async (nextSettings: AiSettings) => { - if (!nextSettings.apiUrl.trim() && !nextSettings.baseUrl.trim()) { + const updatePermissionStatus = async (nextSettings: BackendSettings) => { + if (!nextSettings.baseUrl.trim()) { setPermissionStatus("missing") return false } @@ -138,7 +139,7 @@ function IndexPopup() { let originPattern = "" try { - originPattern = getApiOriginPattern(nextSettings) + originPattern = getBackendOriginPattern(nextSettings) } catch { setPermissionStatus("invalid") return false @@ -158,14 +159,14 @@ function IndexPopup() { } } - const ensureApiPermission = async ( - nextSettings: AiSettings, + const ensureBackendPermission = async ( + nextSettings: BackendSettings, requestIfMissing: boolean ) => { let originPattern = "" try { - originPattern = getApiOriginPattern(nextSettings) + originPattern = getBackendOriginPattern(nextSettings) } catch { setPermissionStatus("invalid") return false @@ -216,7 +217,7 @@ function IndexPopup() { console.error("read active tab failed:", error) } - const loadedSettings = await loadAiSettings() + const loadedSettings = await loadBackendSettings() if (disposed) return @@ -244,16 +245,16 @@ function IndexPopup() { setNotice(null) setResult("") - if (!hasRequiredAiSettings(settings)) { + if (!hasRequiredBackendSettings(settings)) { setActiveView("settings") setNotice({ level: "error", - text: "请先在设置页填写 API Key、接口地址和模型。" + text: "请先在设置页填写 Java 后端地址。" }) return } - const granted = await ensureApiPermission(settings, true) + const granted = await ensureBackendPermission(settings, true) if (!granted) { setActiveView("settings") @@ -267,14 +268,7 @@ function IndexPopup() { setLoading(true) try { - const diff = await fetchDiff(tab.url) - - if (diff.ok === false) { - setNotice({ level: "error", text: diff.message }) - return - } - - const aiResult = await askAi(diff.content, settings) + const aiResult = await generatePrDescription(tab.url, settings) if (aiResult.ok === false) { setNotice({ level: "error", text: aiResult.message }) @@ -282,7 +276,10 @@ function IndexPopup() { } setResult(aiResult.content) - setNotice({ level: "success", text: "PR 描述已生成,可直接粘贴回 GitHub。" }) + setNotice({ + level: "success", + text: "PR 描述已生成,可直接粘贴回 GitHub。" + }) } finally { setLoading(false) } @@ -308,9 +305,9 @@ function IndexPopup() { const isVisibleInPage = (element: HTMLElement | null) => Boolean( element && - (element.offsetWidth || - element.offsetHeight || - element.getClientRects().length) + (element.offsetWidth || + element.offsetHeight || + element.getClientRects().length) ) const textareaSelectors = [ @@ -323,7 +320,9 @@ function IndexPopup() { .map((selector) => document.querySelector(selector) ) - .find((element) => Boolean(element && isVisibleInPage(element))) ?? + .find((element) => + Boolean(element && isVisibleInPage(element)) + ) ?? Array.from( document.querySelectorAll("textarea") ).find((element) => { @@ -378,12 +377,12 @@ function IndexPopup() { } const handleSaveSettings = async () => { - const sanitized = sanitizeAiSettings(settings) + const sanitized = sanitizeBackendSettings(settings) - if (!hasRequiredAiSettings(sanitized)) { + if (!hasRequiredBackendSettings(sanitized)) { setNotice({ level: "error", - text: "请完整填写 API Key、模型、以及 Base URL 或 API URL。" + text: "请填写有效的 Java 后端地址。" }) return } @@ -391,10 +390,10 @@ function IndexPopup() { setSaving(true) try { - await saveAiSettings(sanitized) + await saveBackendSettings(sanitized) setSettings(sanitized) - const granted = await ensureApiPermission(sanitized, true) + const granted = await ensureBackendPermission(sanitized, true) if (granted) { setNotice({ @@ -413,17 +412,17 @@ function IndexPopup() { } const handleTestConnection = async () => { - const sanitized = sanitizeAiSettings(settings) + const sanitized = sanitizeBackendSettings(settings) - if (!hasRequiredAiSettings(sanitized)) { + if (!hasRequiredBackendSettings(sanitized)) { setNotice({ level: "error", - text: "请先填写完整配置再测试连接。" + text: "请先填写 Java 后端地址再测试连接。" }) return } - const granted = await ensureApiPermission(sanitized, true) + const granted = await ensureBackendPermission(sanitized, true) if (!granted) { setNotice({ @@ -436,7 +435,7 @@ function IndexPopup() { setTesting(true) try { - const connectionResult = await testAiConnection(sanitized) + const connectionResult = await testBackendConnection(sanitized) if (connectionResult.ok === false) { setNotice({ level: "error", text: connectionResult.message }) @@ -453,7 +452,7 @@ function IndexPopup() { } const handleGrantPermission = async () => { - const granted = await ensureApiPermission(settings, true) + const granted = await ensureBackendPermission(settings, true) if (granted) { setNotice({ level: "success", text: "接口域名授权成功。" }) @@ -467,9 +466,9 @@ function IndexPopup() { } const handleClearSettings = async () => { - await clearAiSettings() + await clearBackendSettings() - const nextSettings = createDefaultAiSettings() + const nextSettings = createDefaultBackendSettings() setSettings(nextSettings) setResult("") setNotice({ level: "info", text: "本地配置已清空。" }) @@ -478,7 +477,7 @@ function IndexPopup() { const endpointLabel = (() => { try { - return resolveAiApiUrl(settings) + return resolveBackendUrl(settings, "/api/pr-descriptions") } catch { return "地址无效" } @@ -538,15 +537,17 @@ function IndexPopup() { fontSize: 11, fontWeight: 700, color: - settingsLoaded && hasRequiredAiSettings(settings) + settingsLoaded && hasRequiredBackendSettings(settings) ? "#065f46" : "#92400e", backgroundColor: - settingsLoaded && hasRequiredAiSettings(settings) + settingsLoaded && hasRequiredBackendSettings(settings) ? "rgba(16,185,129,0.16)" : "rgba(251,191,36,0.22)" }}> - {settingsLoaded && hasRequiredAiSettings(settings) ? "可生成" : "待配置"} + {settingsLoaded && hasRequiredBackendSettings(settings) + ? "可生成" + : "待配置"} @@ -685,7 +686,7 @@ function IndexPopup() { backgroundColor: "rgba(255, 255, 255, 0.74)", border: "1px dashed rgba(51, 65, 85, 0.24)" }}> - 发送目标: {getApiTargetLabel(settings)} | 域名权限: + Java 后端: {getBackendTargetLabel(settings)} | 域名权限: {`${getPermissionStatusText(permissionStatus)}`} @@ -700,37 +701,24 @@ function IndexPopup() { boxShadow: "0 12px 24px rgba(148, 163, 184, 0.12)" }}>
- 配置仅保存在当前浏览器本地。点击保存时会按接口域名申请权限。 + 浏览器只保存 Java 后端地址和可选访问令牌;AI API Key + 由后端环境变量管理。
- - - setSettings((prev) => ({ ...prev, apiKey: event.target.value })) - } - placeholder="sk-..." - style={{ - borderRadius: 10, - border: "1px solid rgba(148, 163, 184, 0.4)", - backgroundColor: "#fff", - padding: "9px 10px", - fontSize: 12 - }} - /> - - setSettings((prev) => ({ ...prev, baseUrl: event.target.value })) + setSettings((prev) => ({ + ...prev, + baseUrl: event.target.value + })) } - placeholder="https://api.moonshot.cn/v1" + placeholder="http://localhost:8080" style={{ borderRadius: 10, border: "1px solid rgba(148, 163, 184, 0.4)", @@ -741,32 +729,18 @@ function IndexPopup() { /> - setSettings((prev) => ({ ...prev, apiUrl: event.target.value })) - } - placeholder="https://xxx/v1/chat/completions" - style={{ - borderRadius: 10, - border: "1px solid rgba(148, 163, 184, 0.4)", - backgroundColor: "#fff", - padding: "9px 10px", - fontSize: 12 - }} - /> - - - - setSettings((prev) => ({ ...prev, model: event.target.value })) + setSettings((prev) => ({ + ...prev, + accessToken: event.target.value + })) } - placeholder="moonshot-v1-8k" + placeholder="与 APP_SECURITY_TOKEN 保持一致" style={{ borderRadius: 10, border: "1px solid rgba(148, 163, 184, 0.4)", @@ -834,7 +808,8 @@ function IndexPopup() { lineHeight: 1.5, color: "#475569" }}> - 说明: 仅在你点击“生成/测试”时向配置的接口发请求,不会把 Diff 上传到开发者自建服务器。 + 说明: 点击“生成”后,当前 GitHub 页面地址会发送给 Java + 后端;后端负责抓取 Diff 并调用 AI 服务。
)} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo deleted file mode 100644 index a21916f..0000000 --- a/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"program":{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/plasmo/templates/plasmo.d.ts","./.plasmo/process.env.d.ts","./.plasmo/index.d.ts","./lib/settings.ts","./lib/ai.ts","./popup.tsx","./node_modules/@types/har-format/index.d.ts","./node_modules/@types/chrome/har-format/index.d.ts","./node_modules/@types/chrome/chrome-cast/index.d.ts","./node_modules/@types/filewriter/index.d.ts","./node_modules/@types/filesystem/index.d.ts","./node_modules/@types/chrome/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/@types/http-cache-semantics/index.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/dom-events.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/globals.global.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/parse-json/index.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/relateurl/index.d.ts","./node_modules/@types/scheduler/index.d.ts","./node_modules/@types/trusted-types/lib/index.d.ts","./node_modules/@types/trusted-types/index.d.ts"],"fileInfos":[{"version":"f33e5332b24c3773e930e212cbb8b6867c8ba3ec4492064ea78e55a524d57450","affectsGlobalScope":true},"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","26f2f787e82c4222710f3b676b4d83eb5ad0a72fa7b746f03449e7a026ce5073","9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","5514e54f17d6d74ecefedc73c504eadffdeda79c7ea205cf9febead32d45c4bc","1c0cdb8dc619bc549c3e5020643e7cf7ae7940058e8c7e5aefa5871b6d86f44b","bed7b7ba0eb5a160b69af72814b4dde371968e40b6c5e73d3a9f7bee407d158c",{"version":"21e41a76098aa7a191028256e52a726baafd45a925ea5cf0222eb430c96c1d83","affectsGlobalScope":true},{"version":"35299ae4a62086698444a5aaee27fc7aa377c68cbb90b441c9ace246ffd05c97","affectsGlobalScope":true},{"version":"138fb588d26538783b78d1e3b2c2cc12d55840b97bf5e08bca7f7a174fbe2f17","affectsGlobalScope":true},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true},{"version":"4443e68b35f3332f753eacc66a04ac1d2053b8b035a0e0ac1d455392b5e243b3","affectsGlobalScope":true},{"version":"bc47685641087c015972a3f072480889f0d6c65515f12bd85222f49a98952ed7","affectsGlobalScope":true},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true},{"version":"6fc23bb8c3965964be8c597310a2878b53a0306edb71d4b5a4dfe760186bcc01","affectsGlobalScope":true},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true},{"version":"bb42a7797d996412ecdc5b2787720de477103a0b2e53058569069a0e2bae6c7e","affectsGlobalScope":true},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true},{"version":"61c37c1de663cf4171e1192466e52c7a382afa58da01b1dc75058f032ddf0839","affectsGlobalScope":true},{"version":"b541a838a13f9234aba650a825393ffc2292dc0fc87681a5d81ef0c96d281e7a","affectsGlobalScope":true},{"version":"e0275cd0e42990dc3a16f0b7c8bca3efe87f1c8ad404f80c6db1c7c0b828c59f","affectsGlobalScope":true},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true},{"version":"49ed889be54031e1044af0ad2c603d627b8bda8b50c1a68435fe85583901d072","affectsGlobalScope":true},{"version":"e93d098658ce4f0c8a0779e6cab91d0259efb88a318137f686ad76f8410ca270","affectsGlobalScope":true},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true},{"version":"bf14a426dbbf1022d11bd08d6b8e709a2e9d246f0c6c1032f3b2edb9a902adbe","affectsGlobalScope":true},{"version":"ec0104fee478075cb5171e5f4e3f23add8e02d845ae0165bfa3f1099241fa2aa","affectsGlobalScope":true},{"version":"2b72d528b2e2fe3c57889ca7baef5e13a56c957b946906d03767c642f386bbc3","affectsGlobalScope":true},{"version":"acae90d417bee324b1372813b5a00829d31c7eb670d299cd7f8f9a648ac05688","affectsGlobalScope":true},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true},{"version":"51e547984877a62227042850456de71a5c45e7fe86b7c975c6e68896c86fa23b","affectsGlobalScope":true},{"version":"62a4966981264d1f04c44eb0f4b5bdc3d81c1a54725608861e44755aa24ad6a5","affectsGlobalScope":true},{"version":"4fa6ed14e98aa80b91f61b9805c653ee82af3502dc21c9da5268d3857772ca05","affectsGlobalScope":true},{"version":"e6633e05da3ff36e6da2ec170d0d03ccf33de50ca4dc6f5aeecb572cedd162fb","affectsGlobalScope":true},{"version":"86a34c7a13de9cabc43161348f663624b56871ed80986e41d214932ddd8d6719","affectsGlobalScope":true},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true},{"version":"caccc56c72713969e1cfe5c3d44e5bab151544d9d2b373d7dbe5a1e4166652be","affectsGlobalScope":true},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true},{"version":"50d53ccd31f6667aff66e3d62adf948879a3a16f05d89882d1188084ee415bbc","affectsGlobalScope":true},{"version":"08a58483392df5fcc1db57d782e87734f77ae9eab42516028acbfe46f29a3ef7","affectsGlobalScope":true},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true},{"version":"13f6e6380c78e15e140243dc4be2fa546c287c6d61f4729bc2dd7cf449605471","affectsGlobalScope":true},{"version":"4350e5922fecd4bedda2964d69c213a1436349d0b8d260dd902795f5b94dc74b","affectsGlobalScope":true},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true},{"version":"33358442698bb565130f52ba79bfd3d4d484ac85fe33f3cb1759c54d18201393","affectsGlobalScope":true},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true},{"version":"0bd5e7096c7bc02bf70b2cc017fc45ef489cb19bd2f32a71af39ff5787f1b56a","affectsGlobalScope":true},"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc",{"version":"e6f3077b1780226627f76085397d10c77a4d851c7154fd4b3f1eb114f4c2e56d","affectsGlobalScope":true},{"version":"97ae124daa3695481c12e517f1aadb524b12fdb1f09d67f9ecc3328c12271487","affectsGlobalScope":true},{"version":"841ca4d215dee01bb675e6fe782a9efc269a4c9d71509cfce75fdbe97f94adae","affectsGlobalScope":true},"45ccc69e3952ff11e3272e7f09c12d252ec906c8ff99cd484c24fe6ae5350a36",{"version":"98b2bede66822f770c559c5a945fadc7ab393cf0b5101e23b925f215cd465470","signature":"44ef5edd3e1602c141f85869c0e668ad7bfbd0d6cf504b4a88f3facb4b67b2d7"},{"version":"93b53fc0de769d4f76fba25dd2325645a1d8e229767c49bafc6e50fd132813d6","signature":"9c8ee8280730c4021e70e974c2d7a3be16acbd0ddbc1b0f6f43505475a81e8e3"},{"version":"c8a13e4a239e50ba3b42aefa4b7aca8ee73951833099daec64f06c746c788f19","signature":"800ba4c36c2880f27faf5d49058422676ff294f3685eb4ae1955c542247ce9dc"},"5574d520dabc450de6be799f1791d86d71da4fb236f16e6ca21b953788bb5154",{"version":"5f877dfc985d1fd3ac8bf4a75cd77b06c42ca608809b324c44b4151758de7189","affectsGlobalScope":true},{"version":"f9a60e36a4cc38129e1882b28e24bd1d47f2bf62e7708d611384b223f31ad20b","affectsGlobalScope":true},{"version":"14c2fd6220654a41c53836a62ba96d4b515ae1413b0ccb31c2445fb1ae1de5de","affectsGlobalScope":true},{"version":"4f29c38739500cd35a2ce41d15a35e34445ca755ebb991915b5f170985a49d21","affectsGlobalScope":true},{"version":"d3842a6977bc70be229c3397123adaa686d99e161c9927ae85b6f6890be401e7","affectsGlobalScope":true},"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","4f6ae308c5f2901f2988c817e1511520619e9025b9b12cc7cce2ab2e6ffed78a","efc7d584a33fe3422847783d228f315c4cd1afe74bd7cf8e3f0e4c1125129fef","7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","4967529644e391115ca5592184d4b63980569adf60ee685f968fd59ab1557188","5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","7180c03fd3cb6e22f911ce9ba0f8a7008b1a6ddbe88ccf16a9c8140ef9ac1686","25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","54cb85a47d760da1c13c00add10d26b5118280d44d58e6908d8e89abbd9d7725","3e4825171442666d31c845aeb47fcd34b62e14041bb353ae2b874285d78482aa","c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","a967bfe3ad4e62243eb604bf956101e4c740f5921277c60debaf325c1320bf88","e9775e97ac4877aebf963a0289c81abe76d1ec9a2a7778dbe637e5151f25c5f3","471e1da5a78350bc55ef8cef24eb3aca6174143c281b8b214ca2beda51f5e04a","cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","db3435f3525cd785bf21ec6769bf8da7e8a776be1a99e2e7efb5f244a2ef5fee","c3b170c45fc031db31f782e612adf7314b167e60439d304b49e704010e7bafe5","40383ebef22b943d503c6ce2cb2e060282936b952a01bea5f9f493d5fb487cc7","4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","3a84b7cb891141824bd00ef8a50b6a44596aded4075da937f180c90e362fe5f6","13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","33203609eba548914dc83ddf6cadbc0bcb6e8ef89f6d648ca0908ae887f9fcc5","0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","e53a3c2a9f624d90f24bf4588aacd223e7bec1b9d0d479b68d2f4a9e6011147f","339dc5265ee5ed92e536a93a04c4ebbc2128f45eeec6ed29f379e0085283542c","9f0a92164925aa37d4a5d9dd3e0134cff8177208dba55fd2310cd74beea40ee2","8bfdb79bf1a9d435ec48d9372dc93291161f152c0865b81fc0b2694aedb4578d","2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","d32275be3546f252e3ad33976caf8c5e842c09cb87d468cb40d5f4cf092d1acc","4a0c3504813a3289f7fb1115db13967c8e004aa8e4f8a9021b95285502221bd1",{"version":"a14ed46fa3f5ffc7a8336b497cd07b45c2084213aaca933a22443fcb2eef0d07","affectsGlobalScope":true},"cce1f5f86974c1e916ec4a8cab6eec9aa8e31e8148845bf07fbaa8e1d97b1a2c",{"version":"e2eb1ce13a9c0fa7ab62c63909d81973ef4b707292667c64f1e25e6e53fa7afa","affectsGlobalScope":true},"16d74fe4d8e183344d3beb15d48b123c5980ff32ff0cc8c3b96614ddcdf9b239","7b43160a49cf2c6082da0465876c4a0b164e160b81187caeb0a6ca7a281e85ba",{"version":"41fb2a1c108fbf46609ce5a451b7ec78eb9b5ada95fd5b94643e4b26397de0b3","affectsGlobalScope":true},"a40826e8476694e90da94aa008283a7de50d1dafd37beada623863f1901cb7fb","1b282e90846fada1e96dc1cf5111647d6ab5985c8d7b5c542642f1ea2739406d","bd3f5d05b6b5e4bfcea7739a45f3ffb4a7f4a3442ba7baf93e0200799285b8f1","4c775c2fccabf49483c03cd5e3673f87c1ffb6079d98e7b81089c3def79e29c6","8806ae97308ef26363bd7ec8071bca4d07fb575f905ee3d8a91aff226df6d618","af5bf1db6f1804fb0069039ae77a05d60133c77a2158d9635ea27b6bb2828a8f","b7fe70be794e13d1b7940e318b8770cd1fb3eced7707805318a2e3aaac2c3e9e",{"version":"2c71199d1fc83bf17636ad5bf63a945633406b7b94887612bba4ef027c662b3e","affectsGlobalScope":true},{"version":"7ae9dc7dbb58cd843065639707815df85c044babaa0947116f97bdb824d07204","affectsGlobalScope":true},"7aae1df2053572c2cfc2089a77847aadbb38eedbaa837a846c6a49fb37c6e5bd","313a0b063f5188037db113509de1b934a0e286f14e9479af24fada241435e707","f1ace2d2f98429e007d017c7a445efad2aaebf8233135abdb2c88b8c0fef91ab","87ef1a23caa071b07157c72077fa42b86d30568f9dc9e31eed24d5d14fc30ba8","396a8939b5e177542bdf9b5262b4eee85d29851b2d57681fa9d7eae30e225830","21773f5ac69ddf5a05636ba1f50b5239f4f2d27e4420db147fc2f76a5ae598ac",{"version":"ea455cc68871b049bcecd9f56d4cf27b852d6dafd5e3b54468ca87cc11604e4d","affectsGlobalScope":true},"a5fe4cc622c3bf8e09ababde5f4096ceac53163eefcd95e9cd53f062ff9bb67a","45b1053e691c5af9bfe85060a3e1542835f8d84a7e6e2e77ca305251eda0cb3c","0f05c06ff6196958d76b865ae17245b52d8fe01773626ac3c43214a2458ea7b7",{"version":"ae5507fc333d637dec9f37c6b3f4d423105421ea2820a64818de55db85214d66","affectsGlobalScope":true},{"version":"0666f4c99b8688c7be5956df8fecf5d1779d3b22f8f2a88258ae7072c7b6026f","affectsGlobalScope":true},"8abd0566d2854c4bd1c5e48e05df5c74927187f1541e6770001d9637ac41542e","54e854615c4eafbdd3fd7688bd02a3aafd0ccf0e87c98f79d3e9109f047ce6b8","d8dba11dc34d50cb4202de5effa9a1b296d7a2f4a029eec871f894bddfb6430d","8b71dd18e7e63b6f991b511a201fad7c3bf8d1e0dd98acb5e3d844f335a73634","01d8e1419c84affad359cc240b2b551fb9812b450b4d3d456b64cda8102d4f60","8221b00f271cf7f535a8eeec03b0f80f0929c7a16116e2d2df089b41066de69b","269929a24b2816343a178008ac9ae9248304d92a8ba8e233055e0ed6dbe6ef71","93452d394fdd1dc551ec62f5042366f011a00d342d36d50793b3529bfc9bd633","7424817d5eb498771e6d1808d726ec38f75d2eaf3fa359edd5c0c540c52725c1","831c22d257717bf2cbb03afe9c4bcffc5ccb8a2074344d4238bf16d3a857bb12",{"version":"bddce945d552a963c9733db106b17a25474eefcab7fc990157a2134ef55d4954","affectsGlobalScope":true},{"version":"7052b7b0c3829df3b4985bab2fd74531074b4835d5a7b263b75c82f0916ad62f","affectsGlobalScope":true},"aa34c3aa493d1c699601027c441b9664547c3024f9dbab1639df7701d63d18fa","eefcdf86cefff36e5d87de36a3638ab5f7d16c2b68932be4a72c14bb924e43c1","7c651f8dce91a927ab62925e73f190763574c46098f2b11fb8ddc1b147a6709a","7440ab60f4cb031812940cc38166b8bb6fbf2540cfe599f87c41c08011f0c1df",{"version":"4d0405568cf6e0ff36a4861c4a77e641366feaefa751600b0a4d12a5e8f730a8","affectsGlobalScope":true},{"version":"f5b5dc128973498b75f52b1b8c2d5f8629869104899733ae485100c2309b4c12","affectsGlobalScope":true},"e393915d3dc385e69c0e2390739c87b2d296a610662eb0b1cb85224e55992250","79bad8541d5779c85e82a9fb119c1fe06af77a71cc40f869d62ad379473d4b75","8013f6c4d1632da8f1c4d3d702ae559acccd0f1be05360c31755f272587199c9",{"version":"629d20681ca284d9e38c0a019f647108f5fe02f9c59ac164d56f5694fc3faf4d","affectsGlobalScope":true},"e7dbf5716d76846c7522e910896c5747b6df1abd538fee8f5291bdc843461795",{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true},"b510d0a18e3db42ac9765d26711083ec1e8b4e21caaca6dc4d25ae6e8623f447","916be7d770b0ae0406be9486ac12eb9825f21514961dd050594c4b250617d5a8","7ac7ef12f7ece6464d83d2d56fea727260fb954fdd51a967e94f97b8595b714b","d298f6aca49ec8b97f4e972aae1299d5cd2f72ac566e0f179f168f8130be1ba3","4ef960df4f672e93b479f88211ed8b5cfa8a598b97aafa3396cacdc3341e3504","15fe687c59d62741b4494d5e623d497d55eb38966ecf5bea7f36e48fc3fbe15e",{"version":"2c3b8be03577c98530ef9cb1a76e2c812636a871f367e9edf4c5f3ce702b77f8","affectsGlobalScope":true}],"root":[70,[72,75]],"options":{"allowJs":true,"declaration":false,"declarationMap":false,"esModuleInterop":true,"inlineSources":false,"jsx":1,"module":99,"noUnusedLocals":false,"noUnusedParameters":false,"skipLibCheck":true,"strict":false,"target":99},"fileIdsList":[[73],[76],[77,78,80],[79],[84],[120],[121,126,154],[122,133,134,141,151,162],[122,123,133,141],[124,163],[125,126,134,142],[126,151,159],[127,129,133,141],[120,128],[129,130],[133],[131,133],[120,133],[133,134,135,151,162],[133,134,135,148,151,154],[118,121,167],[129,133,136,141,151,162],[133,134,136,137,141,151,159,162],[136,138,151,159,162],[84,85,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169],[133,139],[140,162,167],[129,133,141,151],[142],[143],[120,144],[145,161,167],[146],[147],[133,148,149],[148,150,163,165],[121,133,151,152,153,154],[121,151,153],[151,152],[154],[155],[120,151],[133,157,158],[157,158],[126,141,151,159],[160],[141,161],[121,136,147,162],[126,163],[151,164],[140,165],[166],[121,126,133,135,144,151,162,165,167],[151,168],[69],[66,67,68],[175],[95,99,162],[95,151,162],[90],[92,95,159,162],[141,159],[170],[90,170],[92,95,141,162],[87,88,91,94,121,133,151,162],[87,93],[91,95,121,154,162,170],[121,170],[111,121,170],[89,90,170],[95],[89,90,91,92,93,94,95,96,97,99,100,101,102,103,104,105,106,107,108,109,110,112,113,114,115,116,117],[95,102,103],[93,95,103,104],[94],[87,90,95],[95,99,103,104],[99],[93,95,98,162],[87,92,93,95,99,102],[121,151],[90,95,111,121,167,170],[69,73,74]],"referencedMap":[[74,1],[77,2],[81,3],[80,4],[84,5],[85,5],[120,6],[121,7],[122,8],[123,9],[124,10],[125,11],[126,12],[127,13],[128,14],[129,15],[130,15],[132,16],[131,17],[133,18],[134,19],[135,20],[119,21],[136,22],[137,23],[138,24],[170,25],[139,26],[140,27],[141,28],[142,29],[143,30],[144,31],[145,32],[146,33],[147,34],[148,35],[149,35],[150,36],[151,37],[153,38],[152,39],[154,40],[155,41],[156,42],[157,43],[158,44],[159,45],[160,46],[161,47],[162,48],[163,49],[164,50],[165,51],[166,52],[167,53],[168,54],[172,55],[69,56],[176,57],[70,55],[102,58],[109,59],[101,58],[116,60],[93,61],[92,62],[115,63],[110,64],[113,65],[95,66],[94,67],[90,68],[89,69],[112,70],[91,71],[96,72],[100,72],[118,73],[117,72],[104,74],[105,75],[107,76],[103,77],[106,78],[111,63],[98,79],[99,80],[108,81],[88,82],[114,83],[75,84]],"exportedModulesMap":[[74,1],[77,2],[81,3],[80,4],[84,5],[85,5],[120,6],[121,7],[122,8],[123,9],[124,10],[125,11],[126,12],[127,13],[128,14],[129,15],[130,15],[132,16],[131,17],[133,18],[134,19],[135,20],[119,21],[136,22],[137,23],[138,24],[170,25],[139,26],[140,27],[141,28],[142,29],[143,30],[144,31],[145,32],[146,33],[147,34],[148,35],[149,35],[150,36],[151,37],[153,38],[152,39],[154,40],[155,41],[156,42],[157,43],[158,44],[159,45],[160,46],[161,47],[162,48],[163,49],[164,50],[165,51],[166,52],[167,53],[168,54],[172,55],[69,56],[176,57],[70,55],[102,58],[109,59],[101,58],[116,60],[93,61],[92,62],[115,63],[110,64],[113,65],[95,66],[94,67],[90,68],[89,69],[112,70],[91,71],[96,72],[100,72],[118,73],[117,72],[104,74],[105,75],[107,76],[103,77],[106,78],[111,63],[98,79],[99,80],[108,81],[88,82],[114,83],[75,55]],"semanticDiagnosticsPerFile":[72,71,74,73,78,77,81,82,80,79,76,83,84,85,120,121,122,123,124,125,126,127,128,129,130,132,131,133,134,135,119,169,136,137,138,170,139,140,141,142,143,144,145,146,147,148,149,150,151,153,152,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,171,68,172,66,69,173,174,176,175,86,67,70,64,65,12,13,15,14,2,16,17,18,19,20,21,22,23,3,4,24,28,25,26,27,29,30,31,5,32,33,34,35,6,39,36,37,38,40,7,41,46,47,42,43,44,45,8,51,48,49,50,52,9,53,54,55,58,56,57,59,60,10,1,11,63,62,61,102,109,101,116,93,92,115,110,113,95,94,90,89,112,91,96,97,100,87,118,117,104,105,107,103,106,111,98,99,108,88,114,75],"affectedFilesPendingEmit":[74,73,75]},"version":"5.3.3"} \ No newline at end of file