├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── api ├── .gitignore ├── Dockerfile ├── bun.lock ├── package.json └── src │ ├── index.ts │ ├── services │ ├── auth.ts │ ├── generateReport.ts │ ├── sessionStore.ts │ └── tokenStorage.ts │ ├── types │ ├── data.ts │ └── presets.ts │ └── utils │ └── presets.ts ├── compose.yml ├── docs ├── assets │ ├── report-showcase.png │ └── site-showcase.png └── selfhost.md └── web ├── .gitignore ├── Dockerfile ├── README.md ├── bun.lock ├── components.json ├── eslint.config.js ├── index.html ├── nginx.conf ├── package.json ├── public ├── by-logo.png ├── favicon-dark.png ├── favicon-light.png ├── favicon.png └── vite.svg ├── src ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── ThemeToggle.tsx │ ├── report │ │ ├── ReportActions.tsx │ │ ├── ReportContent.tsx │ │ ├── ReportError.tsx │ │ ├── ReportFormatter.tsx │ │ ├── ReportHeader.tsx │ │ ├── ReportLoading.tsx │ │ └── imageGenerator.ts │ ├── theme-provider.tsx │ └── ui │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── select.tsx ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── pages │ ├── Callback.tsx │ ├── Dashboard.tsx │ ├── Login.tsx │ └── Report.tsx ├── utils │ └── reportUtils.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | APP_ID=114514 # Github Apps ID 2 | OAUTH_CLIENT_ID= # Github Apps Client ID 3 | OAUTH_CLIENT_SECRET= # Github Apps Client Secret 4 | REDIS_URL=redis://gitbox-redis:6379 5 | VITE_HOST=https://gitbox.hust.online 6 | PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 7 | 8 | -----END RSA PRIVATE KEY-----" 9 | 10 | OPENAI_API_BASE_URL=https://openrouter.ai/api/v1 11 | OPENAI_API_KEY= 12 | OPENAI_MODEL=deepseek/deepseek-chat-v3-0324:free -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.prod.* 2 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bingyan Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

GitHub Analyzer | GitHub 锐评生成器

3 |

锐评一下你都在 GitHub 写了什么

4 |

体验地址 | 5 | Docker 部署

6 |
7 | 8 | ## 项目介绍 9 | 10 | 本项目旨在分析 GitHub 上的项目和用户活动,通过 AI 生成有趣的评论和总结。 11 | 12 | ## 功能 13 | 14 | 1. 生成 GitHub 锐评 15 | 2. 生成图片报告以供分享 16 | 3. 支持自部署! 17 | 18 | ## 看看效果? 19 | 20 | 21 | 22 | ## Star History 23 | 24 | 25 | 26 | 27 | 28 | Star History Chart 29 | 30 | 31 | 32 | # LICENSE 33 | 34 | ``` 35 | MIT License 36 | 37 | Copyright (c) 2025 Bingyan Studio 38 | 39 | Permission is hereby granted, free of charge, to any person obtaining a copy 40 | of this software and associated documentation files (the "Software"), to deal 41 | in the Software without restriction, including without limitation the rights 42 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 43 | copies of the Software, and to permit persons to whom the Software is 44 | furnished to do so, subject to the following conditions: 45 | 46 | The above copyright notice and this permission notice shall be included in all 47 | copies or substantial portions of the Software. 48 | 49 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 50 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 51 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 52 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 53 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 54 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 55 | SOFTWARE. 56 | ``` 57 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # Bun 2 | node_modules/ 3 | .env 4 | .env.local 5 | .env.development.local 6 | .env.test.local 7 | .env.production.local 8 | 9 | # Build output 10 | dist/ 11 | build/ 12 | out/ 13 | .output/ 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | bun-debug.log* 22 | 23 | # Editor directories and files 24 | .idea/ 25 | .vscode/ 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | .DS_Store 32 | 33 | # Testing 34 | coverage/ 35 | .nyc_output/ 36 | 37 | # Temporary files 38 | .tmp/ 39 | .temp/ 40 | 41 | dump.rdb -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | COPY package.json bun.lock ./ 4 | RUN bun install --frozen-lockfile 5 | COPY src ./ 6 | 7 | EXPOSE 3000/tcp 8 | ENTRYPOINT [ "bun", "run", "index.ts" ] -------------------------------------------------------------------------------- /api/bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "dependencies": { 6 | "@octokit/auth-app": "^7.1.6", 7 | "@types/bun": "^1.2.10", 8 | "octokit": "^4.1.3", 9 | "openai": "^4.95.1", 10 | "redis": "^4.6.10", 11 | }, 12 | }, 13 | }, 14 | "packages": { 15 | "@octokit/app": ["@octokit/app@15.1.6", "https://mirrors.huaweicloud.com/repository/npm/@octokit/app/-/app-15.1.6.tgz", { "dependencies": { "@octokit/auth-app": "^7.2.1", "@octokit/auth-unauthenticated": "^6.1.3", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/types": "^14.0.0", "@octokit/webhooks": "^13.6.1" } }, "sha512-WELCamoCJo9SN0lf3SWZccf68CF0sBNPQuLYmZ/n87p5qvBJDe9aBtr5dHkh7T9nxWZ608pizwsUbypSzZAiUw=="], 16 | 17 | "@octokit/auth-app": ["@octokit/auth-app@7.2.1", "https://mirrors.huaweicloud.com/repository/npm/@octokit/auth-app/-/auth-app-7.2.1.tgz", { "dependencies": { "@octokit/auth-oauth-app": "^8.1.4", "@octokit/auth-oauth-user": "^5.1.4", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-4jaopCVOtWN0V8qCx/1s2pkRqC6tcvIQM3kFB99eIpsP53GfsoIKO08D94b83n/V3iGihHmxWR2lXzE0NicUGg=="], 18 | 19 | "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@8.1.4", "https://mirrors.huaweicloud.com/repository/npm/@octokit/auth-oauth-app/-/auth-oauth-app-8.1.4.tgz", { "dependencies": { "@octokit/auth-oauth-device": "^7.1.5", "@octokit/auth-oauth-user": "^5.1.4", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ=="], 20 | 21 | "@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@7.1.5", "https://mirrors.huaweicloud.com/repository/npm/@octokit/auth-oauth-device/-/auth-oauth-device-7.1.5.tgz", { "dependencies": { "@octokit/oauth-methods": "^5.1.5", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw=="], 22 | 23 | "@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@5.1.4", "https://mirrors.huaweicloud.com/repository/npm/@octokit/auth-oauth-user/-/auth-oauth-user-5.1.4.tgz", { "dependencies": { "@octokit/auth-oauth-device": "^7.1.5", "@octokit/oauth-methods": "^5.1.5", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-4tJRofMHm6ZCd3O2PVgboBbQ/lNtacREeaihet0+wCATZmvPK+jjg2K6NjBfY69An3yzQdmkcMeiaOOoxOPr7Q=="], 24 | 25 | "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "https://mirrors.huaweicloud.com/repository/npm/@octokit/auth-token/-/auth-token-5.1.2.tgz", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], 26 | 27 | "@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@6.1.3", "https://mirrors.huaweicloud.com/repository/npm/@octokit/auth-unauthenticated/-/auth-unauthenticated-6.1.3.tgz", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA=="], 28 | 29 | "@octokit/core": ["@octokit/core@6.1.5", "https://mirrors.huaweicloud.com/repository/npm/@octokit/core/-/core-6.1.5.tgz", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg=="], 30 | 31 | "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "https://mirrors.huaweicloud.com/repository/npm/@octokit/endpoint/-/endpoint-10.1.4.tgz", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], 32 | 33 | "@octokit/graphql": ["@octokit/graphql@8.2.2", "https://mirrors.huaweicloud.com/repository/npm/@octokit/graphql/-/graphql-8.2.2.tgz", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], 34 | 35 | "@octokit/oauth-app": ["@octokit/oauth-app@7.1.6", "https://mirrors.huaweicloud.com/repository/npm/@octokit/oauth-app/-/oauth-app-7.1.6.tgz", { "dependencies": { "@octokit/auth-oauth-app": "^8.1.3", "@octokit/auth-oauth-user": "^5.1.3", "@octokit/auth-unauthenticated": "^6.1.2", "@octokit/core": "^6.1.4", "@octokit/oauth-authorization-url": "^7.1.1", "@octokit/oauth-methods": "^5.1.4", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-OMcMzY2WFARg80oJNFwWbY51TBUfLH4JGTy119cqiDawSFXSIBujxmpXiKbGWQlvfn0CxE6f7/+c6+Kr5hI2YA=="], 36 | 37 | "@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@7.1.1", "https://mirrors.huaweicloud.com/repository/npm/@octokit/oauth-authorization-url/-/oauth-authorization-url-7.1.1.tgz", {}, "sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA=="], 38 | 39 | "@octokit/oauth-methods": ["@octokit/oauth-methods@5.1.5", "https://mirrors.huaweicloud.com/repository/npm/@octokit/oauth-methods/-/oauth-methods-5.1.5.tgz", { "dependencies": { "@octokit/oauth-authorization-url": "^7.0.0", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw=="], 40 | 41 | "@octokit/openapi-types": ["@octokit/openapi-types@25.0.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", {}, "sha512-FZvktFu7HfOIJf2BScLKIEYjDsw6RKc7rBJCdvCTfKsVnx2GEB/Nbzjr29DUdb7vQhlzS/j8qDzdditP0OC6aw=="], 42 | 43 | "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@10.4.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-10.4.0.tgz", {}, "sha512-HMiF7FUiVBYfp8pPijMTkWuPELQB6XkPftrnSuK1C1YXaaq2+0ganiQkorEQfXTmhtwlgHJwXT6P8miVhIyjQA=="], 44 | 45 | "@octokit/plugin-paginate-graphql": ["@octokit/plugin-paginate-graphql@5.2.4", "https://mirrors.huaweicloud.com/repository/npm/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-5.2.4.tgz", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA=="], 46 | 47 | "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@12.0.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-12.0.0.tgz", { "dependencies": { "@octokit/types": "^14.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-MPd6WK1VtZ52lFrgZ0R2FlaoiWllzgqFHaSZxvp72NmoDeZ0m8GeJdg4oB6ctqMTYyrnDYp592Xma21mrgiyDA=="], 48 | 49 | "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@14.0.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-14.0.0.tgz", { "dependencies": { "@octokit/types": "^14.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-iQt6ovem4b7zZYZQtdv+PwgbL5VPq37th1m2x2TdkgimIDJpsi2A6Q/OI/23i/hR6z5mL0EgisNR4dcbmckSZQ=="], 50 | 51 | "@octokit/plugin-retry": ["@octokit/plugin-retry@7.2.1", "https://mirrors.huaweicloud.com/repository/npm/@octokit/plugin-retry/-/plugin-retry-7.2.1.tgz", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A=="], 52 | 53 | "@octokit/plugin-throttling": ["@octokit/plugin-throttling@10.0.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/plugin-throttling/-/plugin-throttling-10.0.0.tgz", { "dependencies": { "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^6.1.3" } }, "sha512-Kuq5/qs0DVYTHZuBAzCZStCzo2nKvVRo/TDNhCcpC2TKiOGz/DisXMCvjt3/b5kr6SCI1Y8eeeJTHBxxpFvZEg=="], 54 | 55 | "@octokit/request": ["@octokit/request@9.2.3", "https://mirrors.huaweicloud.com/repository/npm/@octokit/request/-/request-9.2.3.tgz", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w=="], 56 | 57 | "@octokit/request-error": ["@octokit/request-error@6.1.8", "https://mirrors.huaweicloud.com/repository/npm/@octokit/request-error/-/request-error-6.1.8.tgz", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], 58 | 59 | "@octokit/types": ["@octokit/types@14.0.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/types/-/types-14.0.0.tgz", { "dependencies": { "@octokit/openapi-types": "^25.0.0" } }, "sha512-VVmZP0lEhbo2O1pdq63gZFiGCKkm8PPp8AUOijlwPO6hojEVjspA0MWKP7E4hbvGxzFKNqKr6p0IYtOH/Wf/zA=="], 60 | 61 | "@octokit/webhooks": ["@octokit/webhooks@13.8.0", "https://mirrors.huaweicloud.com/repository/npm/@octokit/webhooks/-/webhooks-13.8.0.tgz", { "dependencies": { "@octokit/openapi-webhooks-types": "10.4.0", "@octokit/request-error": "^6.1.7", "@octokit/webhooks-methods": "^5.1.1" } }, "sha512-3PCWyFBNbW2+Ox36VAkSqlPoIb96NZiPcICRYySHZrDTM2NuNxvrjPeaQDj2egqILs9EZFObRTHVMe4XxXJV7w=="], 62 | 63 | "@octokit/webhooks-methods": ["@octokit/webhooks-methods@5.1.1", "https://mirrors.huaweicloud.com/repository/npm/@octokit/webhooks-methods/-/webhooks-methods-5.1.1.tgz", {}, "sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg=="], 64 | 65 | "@redis/bloom": ["@redis/bloom@1.2.0", "https://mirrors.huaweicloud.com/repository/npm/@redis/bloom/-/bloom-1.2.0.tgz", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="], 66 | 67 | "@redis/client": ["@redis/client@1.6.0", "https://mirrors.huaweicloud.com/repository/npm/@redis/client/-/client-1.6.0.tgz", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg=="], 68 | 69 | "@redis/graph": ["@redis/graph@1.1.1", "https://mirrors.huaweicloud.com/repository/npm/@redis/graph/-/graph-1.1.1.tgz", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="], 70 | 71 | "@redis/json": ["@redis/json@1.0.7", "https://mirrors.huaweicloud.com/repository/npm/@redis/json/-/json-1.0.7.tgz", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="], 72 | 73 | "@redis/search": ["@redis/search@1.2.0", "https://mirrors.huaweicloud.com/repository/npm/@redis/search/-/search-1.2.0.tgz", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="], 74 | 75 | "@redis/time-series": ["@redis/time-series@1.1.0", "https://mirrors.huaweicloud.com/repository/npm/@redis/time-series/-/time-series-1.1.0.tgz", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="], 76 | 77 | "@types/aws-lambda": ["@types/aws-lambda@8.10.149", "https://mirrors.huaweicloud.com/repository/npm/@types/aws-lambda/-/aws-lambda-8.10.149.tgz", {}, "sha512-NXSZIhfJjnXqJgtS7IwutqIF/SOy1Wz5Px4gUY1RWITp3AYTyuJS4xaXr/bIJY1v15XMzrJ5soGnPM+7uigZjA=="], 78 | 79 | "@types/bun": ["@types/bun@1.2.10", "https://mirrors.huaweicloud.com/repository/npm/@types/bun/-/bun-1.2.10.tgz", { "dependencies": { "bun-types": "1.2.10" } }, "sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg=="], 80 | 81 | "@types/node": ["@types/node@18.19.86", "https://mirrors.huaweicloud.com/repository/npm/@types/node/-/node-18.19.86.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ=="], 82 | 83 | "@types/node-fetch": ["@types/node-fetch@2.6.12", "https://mirrors.huaweicloud.com/repository/npm/@types/node-fetch/-/node-fetch-2.6.12.tgz", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], 84 | 85 | "abort-controller": ["abort-controller@3.0.0", "https://mirrors.huaweicloud.com/repository/npm/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 86 | 87 | "agentkeepalive": ["agentkeepalive@4.6.0", "https://mirrors.huaweicloud.com/repository/npm/agentkeepalive/-/agentkeepalive-4.6.0.tgz", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], 88 | 89 | "asynckit": ["asynckit@0.4.0", "https://mirrors.huaweicloud.com/repository/npm/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 90 | 91 | "before-after-hook": ["before-after-hook@3.0.2", "https://mirrors.huaweicloud.com/repository/npm/before-after-hook/-/before-after-hook-3.0.2.tgz", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], 92 | 93 | "bottleneck": ["bottleneck@2.19.5", "https://mirrors.huaweicloud.com/repository/npm/bottleneck/-/bottleneck-2.19.5.tgz", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], 94 | 95 | "bun-types": ["bun-types@1.2.10", "https://mirrors.huaweicloud.com/repository/npm/bun-types/-/bun-types-1.2.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ=="], 96 | 97 | "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://mirrors.huaweicloud.com/repository/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 98 | 99 | "cluster-key-slot": ["cluster-key-slot@1.1.2", "https://mirrors.huaweicloud.com/repository/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], 100 | 101 | "combined-stream": ["combined-stream@1.0.8", "https://mirrors.huaweicloud.com/repository/npm/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], 102 | 103 | "delayed-stream": ["delayed-stream@1.0.0", "https://mirrors.huaweicloud.com/repository/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], 104 | 105 | "dunder-proto": ["dunder-proto@1.0.1", "https://mirrors.huaweicloud.com/repository/npm/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 106 | 107 | "es-define-property": ["es-define-property@1.0.1", "https://mirrors.huaweicloud.com/repository/npm/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 108 | 109 | "es-errors": ["es-errors@1.3.0", "https://mirrors.huaweicloud.com/repository/npm/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 110 | 111 | "es-object-atoms": ["es-object-atoms@1.1.1", "https://mirrors.huaweicloud.com/repository/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 112 | 113 | "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://mirrors.huaweicloud.com/repository/npm/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], 114 | 115 | "event-target-shim": ["event-target-shim@5.0.1", "https://mirrors.huaweicloud.com/repository/npm/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], 116 | 117 | "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "https://mirrors.huaweicloud.com/repository/npm/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], 118 | 119 | "form-data": ["form-data@4.0.2", "https://mirrors.huaweicloud.com/repository/npm/form-data/-/form-data-4.0.2.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="], 120 | 121 | "form-data-encoder": ["form-data-encoder@1.7.2", "https://mirrors.huaweicloud.com/repository/npm/form-data-encoder/-/form-data-encoder-1.7.2.tgz", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], 122 | 123 | "formdata-node": ["formdata-node@4.4.1", "https://mirrors.huaweicloud.com/repository/npm/formdata-node/-/formdata-node-4.4.1.tgz", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], 124 | 125 | "function-bind": ["function-bind@1.1.2", "https://mirrors.huaweicloud.com/repository/npm/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 126 | 127 | "generic-pool": ["generic-pool@3.9.0", "https://mirrors.huaweicloud.com/repository/npm/generic-pool/-/generic-pool-3.9.0.tgz", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], 128 | 129 | "get-intrinsic": ["get-intrinsic@1.3.0", "https://mirrors.huaweicloud.com/repository/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 130 | 131 | "get-proto": ["get-proto@1.0.1", "https://mirrors.huaweicloud.com/repository/npm/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 132 | 133 | "gopd": ["gopd@1.2.0", "https://mirrors.huaweicloud.com/repository/npm/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 134 | 135 | "has-symbols": ["has-symbols@1.1.0", "https://mirrors.huaweicloud.com/repository/npm/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 136 | 137 | "has-tostringtag": ["has-tostringtag@1.0.2", "https://mirrors.huaweicloud.com/repository/npm/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], 138 | 139 | "hasown": ["hasown@2.0.2", "https://mirrors.huaweicloud.com/repository/npm/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 140 | 141 | "humanize-ms": ["humanize-ms@1.2.1", "https://mirrors.huaweicloud.com/repository/npm/humanize-ms/-/humanize-ms-1.2.1.tgz", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], 142 | 143 | "math-intrinsics": ["math-intrinsics@1.1.0", "https://mirrors.huaweicloud.com/repository/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 144 | 145 | "mime-db": ["mime-db@1.52.0", "https://mirrors.huaweicloud.com/repository/npm/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 146 | 147 | "mime-types": ["mime-types@2.1.35", "https://mirrors.huaweicloud.com/repository/npm/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 148 | 149 | "ms": ["ms@2.1.3", "https://mirrors.huaweicloud.com/repository/npm/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 150 | 151 | "node-domexception": ["node-domexception@1.0.0", "https://mirrors.huaweicloud.com/repository/npm/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], 152 | 153 | "node-fetch": ["node-fetch@2.7.0", "https://mirrors.huaweicloud.com/repository/npm/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], 154 | 155 | "octokit": ["octokit@4.1.3", "https://mirrors.huaweicloud.com/repository/npm/octokit/-/octokit-4.1.3.tgz", { "dependencies": { "@octokit/app": "^15.1.6", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/plugin-rest-endpoint-methods": "^14.0.0", "@octokit/plugin-retry": "^7.2.1", "@octokit/plugin-throttling": "^10.0.0", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-PP+EL8h4xPCE9NBo6jXq6I2/EiTXsn1cg9F0IZehHBv/qhuQpyGMFElEB17miWKciuT6vRHiFFiG9+FoXOmg6A=="], 156 | 157 | "openai": ["openai@4.95.1", "https://mirrors.huaweicloud.com/repository/npm/openai/-/openai-4.95.1.tgz", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-IqJy+ymeW+k/Wq+2YVN3693OQMMcODRtHEYOlz263MdUwnN/Dwdl9c2EXSxLLtGEHkSHAfvzpDMHI5MaWJKXjQ=="], 158 | 159 | "redis": ["redis@4.7.0", "https://mirrors.huaweicloud.com/repository/npm/redis/-/redis-4.7.0.tgz", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.0", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ=="], 160 | 161 | "toad-cache": ["toad-cache@3.7.0", "https://mirrors.huaweicloud.com/repository/npm/toad-cache/-/toad-cache-3.7.0.tgz", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], 162 | 163 | "tr46": ["tr46@0.0.3", "https://mirrors.huaweicloud.com/repository/npm/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], 164 | 165 | "undici-types": ["undici-types@5.26.5", "https://mirrors.huaweicloud.com/repository/npm/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 166 | 167 | "universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "https://mirrors.huaweicloud.com/repository/npm/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="], 168 | 169 | "universal-user-agent": ["universal-user-agent@7.0.2", "https://mirrors.huaweicloud.com/repository/npm/universal-user-agent/-/universal-user-agent-7.0.2.tgz", {}, "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q=="], 170 | 171 | "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "https://mirrors.huaweicloud.com/repository/npm/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], 172 | 173 | "webidl-conversions": ["webidl-conversions@3.0.1", "https://mirrors.huaweicloud.com/repository/npm/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], 174 | 175 | "whatwg-url": ["whatwg-url@5.0.0", "https://mirrors.huaweicloud.com/repository/npm/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 176 | 177 | "yallist": ["yallist@4.0.0", "https://mirrors.huaweicloud.com/repository/npm/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], 178 | 179 | "@types/node-fetch/@types/node": ["@types/node@22.14.1", "https://mirrors.huaweicloud.com/repository/npm/@types/node/-/node-22.14.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], 180 | 181 | "bun-types/@types/node": ["@types/node@22.14.1", "https://mirrors.huaweicloud.com/repository/npm/@types/node/-/node-22.14.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], 182 | 183 | "@types/node-fetch/@types/node/undici-types": ["undici-types@6.21.0", "https://mirrors.huaweicloud.com/repository/npm/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 184 | 185 | "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "https://mirrors.huaweicloud.com/repository/npm/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-analyzer-backend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "bun run src/index.ts" 8 | }, 9 | "dependencies": { 10 | "@octokit/auth-app": "^7.1.6", 11 | "@types/bun": "^1.2.10", 12 | "octokit": "^4.1.3", 13 | "openai": "^4.95.1", 14 | "redis": "^4.6.10" 15 | } 16 | } -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createAppAuth } from "@octokit/auth-app"; 2 | import { serve } from "bun"; 3 | import { Octokit } from "octokit"; 4 | import { getAuthenticatedUser, handleGithubAuth } from "./services/auth"; 5 | import { fetchUserDataEndpoint, generateReport, getPresets } from "./services/generateReport"; 6 | import OpenAI from "openai"; 7 | 8 | process.env.TZ = "Asia/Shanghai"; 9 | 10 | export const appOctokit = new Octokit({ 11 | authStrategy: createAppAuth, 12 | auth: { 13 | appId: process.env.APP_ID, 14 | privateKey: process.env.PRIVATE_KEY, 15 | clientId: process.env.OAUTH_CLIENT_ID, 16 | clientSecret: process.env.OAUTH_CLIENT_SECRET, 17 | }, 18 | }); 19 | 20 | export const openai = new OpenAI({ 21 | baseURL: process.env.OPENAI_API_BASE_URL, 22 | apiKey: process.env.OPENAI_API_KEY, 23 | timeout: 30 * 1000, 24 | maxRetries: 1, 25 | }) 26 | 27 | // Setup Bun's HTTP server 28 | const PORT = process.env.PORT || 3000; 29 | 30 | serve({ 31 | idleTimeout: 180, 32 | port: PORT, 33 | websocket: { 34 | message: () => { }, // Empty handler 35 | }, 36 | routes: { 37 | "/api/code": handleGithubAuth, 38 | "/api/data": fetchUserDataEndpoint, 39 | "/api/user": getAuthenticatedUser, 40 | "/api/report": generateReport, 41 | "/api/presets": getPresets, 42 | }, 43 | error(error) { 44 | console.error(error); 45 | return new Response(`Internal Error: ${error.message}`, { 46 | status: 500, 47 | headers: { 48 | "Content-Type": "text/plain", 49 | }, 50 | }); 51 | }, 52 | }); 53 | 54 | console.log(`Server running on http://localhost:${PORT}`); 55 | // console.log(`Visit http://localhost:${PORT}/login to authenticate with GitHub`); 56 | -------------------------------------------------------------------------------- /api/src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import { BunRequest } from "bun"; 2 | import { appOctokit } from ".."; 3 | import { createOAuthUserAuth } from "@octokit/auth-app"; 4 | import { Octokit } from "octokit"; 5 | import { sessionStore } from "./sessionStore"; 6 | 7 | // User session interface 8 | export interface UserSession { 9 | login: string; 10 | accessToken: string; 11 | refreshToken?: string; 12 | expiresAt?: Date | null; 13 | tokenType?: string; 14 | createdAt: Date; 15 | } 16 | 17 | export async function handleGithubAuth(req: BunRequest) { 18 | console.log("Handling GitHub authentication..."); 19 | const params = new URLSearchParams(req.url.split("?")[1]); 20 | const { code, state } = Object.fromEntries(params.entries()); 21 | 22 | // Get the auth result which includes tokens 23 | const authResult = (await appOctokit.auth({ 24 | type: "oauth-user", 25 | code: code, 26 | state: state, 27 | })) as Record; 28 | 29 | // Create user Octokit instance with the tokens 30 | const userOctokit = new Octokit({ 31 | authStrategy: createOAuthUserAuth, 32 | auth: { 33 | clientId: process.env.GITHUB_CLIENT_ID, 34 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 35 | token: authResult.token, 36 | refreshToken: authResult.refreshToken, 37 | expiresAt: authResult.expiresAt, 38 | tokenType: authResult.tokenType, 39 | }, 40 | }); 41 | 42 | // Get user information 43 | const user = await userOctokit.rest.users.getAuthenticated(); 44 | const username = user.data.login; 45 | console.log("Authenticated user:", username); 46 | 47 | // Create session object 48 | const sessionData: UserSession = { 49 | login: username, 50 | accessToken: authResult.token, 51 | refreshToken: authResult.refreshToken || null, 52 | expiresAt: authResult.expiresAt || null, 53 | tokenType: authResult.tokenType || "bearer", 54 | createdAt: new Date(), 55 | }; 56 | 57 | // Generate session ID and store in Redis 58 | const sessionId = crypto.randomUUID(); 59 | await sessionStore.saveSession(sessionId, sessionData); 60 | 61 | // Calculate expiry time for cookie (optional, can be session cookie) 62 | const cookieExpiry = authResult.expiresAt 63 | ? new Date(authResult.expiresAt).toUTCString() 64 | : undefined; 65 | 66 | // Return a response with a session cookie 67 | const headers = new Headers(); 68 | const cookieOptions = [`session=${sessionId}`, "Path=/", "SameSite=Strict"]; 69 | 70 | if (cookieExpiry) { 71 | cookieOptions.push(`Expires=${cookieExpiry}`); 72 | } 73 | 74 | headers.append("Set-Cookie", cookieOptions.join("; ")); 75 | 76 | return new Response( 77 | JSON.stringify({ 78 | success: true, 79 | user: { login: username }, 80 | }), 81 | { 82 | status: 200, 83 | headers: headers, 84 | } 85 | ); 86 | } 87 | 88 | // Helper function to get user session from request 89 | export async function getUserSession( 90 | req: BunRequest 91 | ): Promise { 92 | const cookies = req.headers.get("cookie"); 93 | if (!cookies) return null; 94 | 95 | const sessionCookie = cookies 96 | .split(";") 97 | .map((c) => c.trim()) 98 | .find((c) => c.startsWith("session=")); 99 | 100 | if (!sessionCookie) return null; 101 | 102 | const sessionId = sessionCookie.split("=")[1]; 103 | return await sessionStore.getSession(sessionId); 104 | } 105 | 106 | // Helper to create an authenticated Octokit instance from a session 107 | export function createOctokitFromSession(session: UserSession): Octokit { 108 | return new Octokit({ 109 | authStrategy: createOAuthUserAuth, 110 | auth: { 111 | clientId: process.env.GITHUB_CLIENT_ID, 112 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 113 | token: session.accessToken, 114 | refreshToken: session.refreshToken, 115 | expiresAt: session.expiresAt, 116 | tokenType: session.tokenType, 117 | }, 118 | }); 119 | } 120 | 121 | export async function getAuthenticatedUser(req: BunRequest) { 122 | const session = await getUserSession(req); 123 | if (!session) 124 | return Response.json( 125 | { 126 | state: "Failed", 127 | message: "No active session found.", 128 | }, 129 | { 130 | status: 401, 131 | } 132 | ); 133 | const octokit = createOctokitFromSession(session); 134 | const user = await octokit.rest.users.getAuthenticated(); 135 | console.log("Authenticated user:", user.data.login); 136 | return Response.json(user.data); 137 | } 138 | -------------------------------------------------------------------------------- /api/src/services/generateReport.ts: -------------------------------------------------------------------------------- 1 | import { BunRequest } from "bun"; 2 | import { sessionStore } from "./sessionStore"; 3 | import { createOctokitFromSession } from "./auth"; 4 | import { Octokit } from "octokit"; 5 | import { openai } from ".."; 6 | import { createClient } from "redis"; 7 | import { presets } from "../utils/presets"; 8 | import { PullRequest, Repository, UserData } from "../types/data"; 9 | 10 | // Redis client setup 11 | const redisClient = createClient({ 12 | url: process.env.REDIS_URL || "redis://localhost:6379", 13 | }); 14 | 15 | // Connect to Redis when the file is loaded 16 | (async () => { 17 | try { 18 | await redisClient.connect(); 19 | console.log("Connected to Redis"); 20 | } catch (err) { 21 | console.error("Redis connection error:", err); 22 | } 23 | })(); 24 | 25 | // Function to get user data from cache 26 | async function getUserDataFromCache(login: string): Promise { 27 | try { 28 | const cachedData = await redisClient.get(`userdata:${login}`); 29 | if (cachedData) { 30 | return JSON.parse(cachedData); 31 | } 32 | return null; 33 | } catch (err) { 34 | console.error("Redis cache get error:", err); 35 | return null; 36 | } 37 | } 38 | 39 | // Function to save user data to cache 40 | async function saveUserDataToCache( 41 | login: string, 42 | data: UserData 43 | ): Promise { 44 | try { 45 | await redisClient.set(`userdata:${login}`, JSON.stringify(data)); 46 | // Set expiration to 1 hour (in seconds) 47 | await redisClient.expire(`userdata:${login}`, 60 * 60); 48 | } catch (err) { 49 | console.error("Redis cache save error:", err); 50 | } 51 | } 52 | 53 | function parseCommits(nodes: any): Repository[] { 54 | return nodes 55 | .filter( 56 | (repo: any) => 57 | repo !== null && 58 | repo.mainCommits !== null && 59 | repo.mainCommits.target !== null 60 | ) 61 | .map((repo: any) => { 62 | const { nameWithOwner, description, mainCommits } = repo; 63 | // Handle null mainCommits with optional chaining and default to empty array 64 | const nodes = mainCommits?.target?.history?.nodes; 65 | const commits = 66 | nodes?.map((commit: any) => { 67 | return { 68 | message: commit.message, 69 | committedDate: new Date(commit.committedDate).toLocaleString( 70 | "zh-CN" 71 | ), 72 | }; 73 | }) || []; 74 | return { 75 | nameWithOwner, 76 | description, 77 | commits: commits.filter((commit: any) => commit !== null), 78 | }; 79 | }) 80 | .filter((repo: any) => repo.commits && repo.commits.length > 0); 81 | } 82 | 83 | function parsePullRequests(nodes: any): PullRequest[] { 84 | return nodes.map((pr) => { 85 | return { 86 | title: pr.title, 87 | body: pr.body, 88 | createdAt: new Date(pr.createdAt).toLocaleString("zh-CN"), 89 | repository: { 90 | nameWithOwner: pr.repository.nameWithOwner, 91 | description: pr.repository.description, 92 | }, 93 | }; 94 | }); 95 | } 96 | 97 | async function fetchUserDataBatched( 98 | octokit: Octokit, 99 | login: string, 100 | userId: string 101 | ): Promise> { 102 | console.log(`Fetching fresh user data for ${login}`); 103 | const commitData: any = await octokit.graphql(` 104 | query { 105 | user(login: "${login}") { 106 | name 107 | bio 108 | location 109 | status { 110 | emoji 111 | message 112 | } 113 | pullRequests(first: 20, orderBy: { 114 | field: CREATED_AT 115 | direction: DESC 116 | }) { 117 | nodes { 118 | repository { 119 | nameWithOwner 120 | description 121 | } 122 | title 123 | body 124 | createdAt 125 | } 126 | } 127 | repositoriesContributedTo( 128 | first: 20 129 | privacy: PUBLIC 130 | orderBy: { 131 | field: UPDATED_AT 132 | direction: DESC 133 | } 134 | includeUserRepositories: true 135 | contributionTypes: [COMMIT] 136 | ) { 137 | nodes { 138 | description 139 | nameWithOwner 140 | mainCommits: defaultBranchRef { 141 | target { 142 | ... on Commit { 143 | history( 144 | first: 30 145 | author: {id: "${userId}"} 146 | since: "2023-04-01T00:00:00Z" 147 | ) { 148 | nodes { 149 | message 150 | committedDate 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | `); 161 | 162 | const parsedCommits: Repository[] = parseCommits( 163 | commitData.user.repositoriesContributedTo.nodes 164 | ); 165 | const parsedPullRequests: PullRequest[] = parsePullRequests( 166 | commitData.user.pullRequests.nodes 167 | ); 168 | 169 | const parsedData: Omit = { 170 | username: login, 171 | name: commitData.user.name, 172 | bio: commitData.user.bio, 173 | location: commitData.user.location, 174 | status: commitData.user.status, 175 | pullRequests: parsedPullRequests, 176 | repositoriesContributedTo: parsedCommits, 177 | }; 178 | 179 | return parsedData; 180 | } 181 | 182 | async function fetchUserDataPaged( 183 | octokit: Octokit, 184 | login: string, 185 | userId: string 186 | ): Promise> { 187 | console.log(`Batching failed. Slowly fetching fresh user data for ${login}`); 188 | const otherData: any = await octokit.graphql(` 189 | query { 190 | user(login: "${login}") { 191 | name 192 | bio 193 | location 194 | status { 195 | emoji 196 | message 197 | } 198 | pullRequests(first: 20, orderBy: { 199 | field: CREATED_AT 200 | direction: DESC 201 | }) { 202 | nodes { 203 | repository { 204 | nameWithOwner 205 | description 206 | } 207 | title 208 | body 209 | createdAt 210 | } 211 | } 212 | } 213 | } 214 | `); 215 | 216 | // Fetch repositories with pagination to get up to 30 repos 217 | let repositoriesData = []; 218 | let hasNextPage = true; 219 | let endCursor = null; 220 | let repoCount = 0; 221 | const maxRepos = 30; // Maximum number of repos to fetch 222 | const perPage = 6; // Number of repos per page 223 | 224 | while (hasNextPage && repoCount < maxRepos) { 225 | const cursorParam = endCursor ? `, after: "${endCursor}"` : ""; 226 | const commitQuery = ` 227 | query { 228 | user(login: "${login}") { 229 | repositoriesContributedTo( 230 | first: ${perPage} 231 | privacy: PUBLIC 232 | orderBy: { 233 | field: UPDATED_AT 234 | direction: DESC 235 | } 236 | includeUserRepositories: true 237 | contributionTypes: [COMMIT] 238 | ${cursorParam} 239 | ) { 240 | pageInfo { 241 | hasNextPage 242 | endCursor 243 | } 244 | nodes { 245 | description 246 | nameWithOwner 247 | mainCommits: defaultBranchRef { 248 | target { 249 | ... on Commit { 250 | history( 251 | first: 30 252 | author: {id: "${userId}"} 253 | since: "2024-04-01T00:00:00Z" 254 | ) { 255 | nodes { 256 | message 257 | committedDate 258 | } 259 | } 260 | } 261 | } 262 | } 263 | } 264 | } 265 | } 266 | } 267 | `; 268 | 269 | const pageData: any = await octokit.graphql(commitQuery); 270 | 271 | const pageNodes = pageData.user.repositoriesContributedTo.nodes || []; 272 | repositoriesData = repositoriesData.concat(pageNodes); 273 | repoCount += pageNodes.length; 274 | 275 | hasNextPage = pageData.user.repositoriesContributedTo.pageInfo.hasNextPage; 276 | endCursor = pageData.user.repositoriesContributedTo.pageInfo.endCursor; 277 | 278 | // Break early if we got fewer repositories than requested per page 279 | if (pageNodes.length < perPage) { 280 | break; 281 | } 282 | } 283 | 284 | const parsedCommits: Repository[] = parseCommits(repositoriesData); 285 | const parsedPullRequests: PullRequest[] = parsePullRequests( 286 | otherData.user.pullRequests.nodes 287 | ); 288 | 289 | const parsedData: Omit = { 290 | username: login, 291 | name: otherData.user.name, 292 | bio: otherData.user.bio, 293 | location: otherData.user.location, 294 | status: otherData.user.status, 295 | pullRequests: parsedPullRequests, 296 | repositoriesContributedTo: parsedCommits, 297 | }; 298 | 299 | return parsedData; 300 | } 301 | 302 | async function fetchUserData(octokit: Octokit): Promise { 303 | const currentUser = await octokit.rest.users.getAuthenticated(); 304 | const { login, node_id: userId } = currentUser.data; 305 | 306 | // Check if we have cached data 307 | const cachedData = await getUserDataFromCache(login); 308 | if (cachedData) { 309 | console.log(`Using cached user data for ${login}`); 310 | return cachedData; 311 | } 312 | 313 | let rawData: Omit | null = null; 314 | try { 315 | const batchedData = await fetchUserDataBatched(octokit, login, userId); 316 | rawData = batchedData; 317 | } catch (err) { 318 | console.error("Error fetching batched user data:", err); 319 | 320 | // Fallback to paged fetching if batched fetching fails 321 | const pagedData = await fetchUserDataPaged(octokit, login, userId); 322 | rawData = pagedData; 323 | } 324 | 325 | let profileContent = ""; 326 | try { 327 | const profileResp = await octokit.rest.repos.getContent({ 328 | owner: login, 329 | repo: login, 330 | path: "README.md", 331 | }); 332 | const profileStatus = profileResp.status; 333 | if (profileStatus !== 200) { 334 | console.error("Failed to fetch profile content:", profileStatus); 335 | profileContent = "Profile content not available."; 336 | } else { 337 | profileContent = Buffer.from( 338 | (profileResp.data as any).content, 339 | "base64" 340 | ).toString("utf-8"); 341 | } 342 | } catch (err) { 343 | console.error("Error fetching profile content:", err); 344 | profileContent = "Profile content not available."; 345 | } 346 | 347 | const parsedData = { 348 | ...rawData, 349 | profileContent: profileContent, 350 | }; 351 | 352 | // Save to cache before returning 353 | await saveUserDataToCache(login, parsedData); 354 | 355 | return parsedData; 356 | } 357 | 358 | function parseUserData(data: UserData): string { 359 | const { 360 | username, 361 | name, 362 | bio, 363 | location, 364 | status, 365 | pullRequests, 366 | repositoriesContributedTo, 367 | profileContent, 368 | } = data; 369 | 370 | return `用户名: ${username} 371 | 昵称: ${name} 372 | 简介: ${bio} 373 | 位置: ${location} 374 | 状态: ${status?.emoji || "无状态"} ${status?.message || "无状态信息"} 375 | PR 记录: 376 | 377 | ${ 378 | pullRequests 379 | ?.map( 380 | (pr) => 381 | `- ${pr.title} (${pr.repository.nameWithOwner})\n ${pr.body}\n 创建于: ${pr.createdAt}` 382 | ) 383 | .join("\n") || "无 PR 记录" 384 | } 385 | 386 | Commit 记录: 387 | 388 | ${ 389 | repositoriesContributedTo 390 | ?.map( 391 | (repo) => 392 | `- ${repo.nameWithOwner} (${repo.description})\n 提交记录:\n ${ 393 | repo?.commits 394 | ?.map((commit) => `- ${commit.message} (${commit.committedDate})`) 395 | .join("\n") || "无 Commit 记录" 396 | }` 397 | ) 398 | .join("\n") || "无 Commit 记录" 399 | } 400 | 401 | 个人资料内容: 402 | 403 | ${profileContent || "无个人资料页"} 404 | `; 405 | } 406 | 407 | // Function to generate a hash key from commit data and preset 408 | function generateReportKey(login: string, presetKey: string): string { 409 | return `${presetKey}:${login}`; 410 | } 411 | 412 | // Get report from Redis 413 | async function getReport(key: string): Promise { 414 | try { 415 | return await redisClient.get(`report:${key}`); 416 | } catch (err) { 417 | console.error("Redis get error:", err); 418 | return null; 419 | } 420 | } 421 | 422 | // Save report to Redis 423 | async function saveReport(key: string, report: string): Promise { 424 | try { 425 | await redisClient.set(`report:${key}`, report); 426 | // Set expiration to 30 days (in seconds) 427 | await redisClient.expire(`report:${key}`, 60 * 60 * 24 * 30); 428 | } catch (err) { 429 | console.error("Redis save error:", err); 430 | } 431 | } 432 | 433 | // Check if a report generation is already pending 434 | async function isReportGenerationPending(key: string): Promise { 435 | try { 436 | const status = await redisClient.get(`pending:${key}`); 437 | return status === "1"; 438 | } catch (err) { 439 | console.error("Redis check pending status error:", err); 440 | return false; 441 | } 442 | } 443 | 444 | // Mark a report generation as pending 445 | async function setReportGenerationPending(key: string): Promise { 446 | try { 447 | await redisClient.set(`pending:${key}`, "1"); 448 | // Set expiration to 10 minutes to avoid orphaned pending statuses 449 | await redisClient.expire(`pending:${key}`, 3 * 60); 450 | } catch (err) { 451 | console.error("Redis set pending status error:", err); 452 | } 453 | } 454 | 455 | // Clear the pending status of a report generation 456 | async function clearReportGenerationPending(key: string): Promise { 457 | try { 458 | await redisClient.del(`pending:${key}`); 459 | } catch (err) { 460 | console.error("Redis clear pending status error:", err); 461 | } 462 | } 463 | 464 | export async function fetchUserDataEndpoint(req: BunRequest) { 465 | const sessionId = req.cookies.get("session"); 466 | if (sessionId === null) { 467 | return Response.json( 468 | { 469 | state: "Failed", 470 | message: "Invalid session ID.", 471 | }, 472 | { 473 | status: 401, 474 | } 475 | ); 476 | } 477 | const session = await sessionStore.getSession(sessionId); 478 | if (session === null) { 479 | return Response.json( 480 | { 481 | state: "Failed", 482 | message: "Invalid session ID.", 483 | }, 484 | { 485 | status: 401, 486 | } 487 | ); 488 | } 489 | const octokit = createOctokitFromSession(session); 490 | 491 | const rawData = await fetchUserData(octokit); 492 | const parsedData = parseUserData(rawData); 493 | return Response.json({ 494 | state: "Success", 495 | raw: rawData, 496 | parsed: parsedData, 497 | }); 498 | } 499 | 500 | export async function generateReport(req: BunRequest) { 501 | const sessionId = req.cookies.get("session"); 502 | if (sessionId === null) { 503 | return Response.json( 504 | { 505 | state: "Failed", 506 | message: "Invalid session ID.", 507 | }, 508 | { 509 | status: 401, 510 | } 511 | ); 512 | } 513 | const session = await sessionStore.getSession(sessionId); 514 | if (session === null) { 515 | return Response.json( 516 | { 517 | state: "Failed", 518 | message: "Invalid session ID.", 519 | }, 520 | { 521 | status: 401, 522 | } 523 | ); 524 | } 525 | 526 | // Get preset key from URL search params 527 | const url = new URL(req.url); 528 | const presetKey = url.searchParams.get("preset") || "man_page"; 529 | const forceRegen = url.searchParams.get("force_regen") === "true"; 530 | 531 | if (!presets[presetKey]) { 532 | return Response.json( 533 | { 534 | state: "Failed", 535 | message: `Preset '${presetKey}' not found.`, 536 | }, 537 | { 538 | status: 400, 539 | } 540 | ); 541 | } 542 | 543 | // Generate a unique key for this report that includes the preset 544 | const reportKey = generateReportKey(session?.login, presetKey); 545 | 546 | // Set up Server-Sent Events response 547 | const stream = new ReadableStream({ 548 | start(controller) { 549 | // Helper function to send SSE events 550 | const sendEvent = (eventType: string, data: any) => { 551 | controller.enqueue( 552 | `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n` 553 | ); 554 | }; 555 | 556 | // Self-invoking async function to handle the report generation process 557 | (async () => { 558 | try { 559 | sendEvent("status", { message: "Checking for cached report..." }); 560 | 561 | // If force_regen is true, delete any existing report 562 | if (forceRegen) { 563 | try { 564 | await redisClient.del(`report:${reportKey}`); 565 | sendEvent("status", { 566 | message: "Forced regeneration, cleared cache.", 567 | }); 568 | } catch (err) { 569 | console.error("Error deleting cached report:", err); 570 | sendEvent("generate_error", { 571 | message: "服务器出现错误,请稍后再试。", 572 | }); 573 | } 574 | } 575 | 576 | // Check if we already have a report for this data 577 | const existingReport = await getReport(reportKey); 578 | if (existingReport && !forceRegen) { 579 | console.log("Returning cached report"); 580 | sendEvent("complete", { message: existingReport }); 581 | controller.close(); 582 | return; 583 | } 584 | 585 | // Check if a report is already being generated for this data 586 | if (await isReportGenerationPending(reportKey)) { 587 | sendEvent("generate_error", { 588 | message: "报告正在生成中,请等待上次生成结束后再试。", 589 | }); 590 | controller.close(); 591 | return; 592 | } 593 | 594 | // Mark this report as pending 595 | await setReportGenerationPending(reportKey); 596 | 597 | // Get the prompt from presets 598 | const initialPrompt = presets[presetKey].prompt; 599 | if (!initialPrompt) { 600 | await clearReportGenerationPending(reportKey); 601 | sendEvent("generate_error", { 602 | message: `Preset '${presetKey}' not found.`, 603 | }); 604 | controller.close(); 605 | return; 606 | } 607 | 608 | sendEvent("status", { message: "正在获取 GitHub 数据..." }); 609 | const octokit = createOctokitFromSession(session); 610 | const rawData = await fetchUserData(octokit); 611 | const parsedData = parseUserData(rawData); 612 | 613 | sendEvent("status", { message: "正在进行深度思考..." }); 614 | 615 | // Check if streaming is enabled (default to true) 616 | const streamingEnabled = url.searchParams.get("stream") !== "false"; 617 | let reportContent = ""; 618 | 619 | if (rawData.username === null) { 620 | await clearReportGenerationPending(reportKey); 621 | sendEvent("generate_error", { 622 | message: `服务器出错,请稍后再试。`, 623 | }); 624 | controller.close(); 625 | return; 626 | } 627 | 628 | const stream = await openai.chat.completions.create({ 629 | messages: [ 630 | { 631 | role: "system", 632 | content: initialPrompt 633 | .replace("{{commit_data}}", parsedData) 634 | .replace("{{username}}", rawData.username), 635 | }, 636 | ], 637 | model: process.env.OPENAI_MODEL || "deepseek-chat", 638 | stream: true, 639 | }); 640 | 641 | // Process the stream 642 | for await (const chunk of stream) { 643 | const content = chunk.choices[0]?.delta?.content || ""; 644 | if (content) { 645 | reportContent += content; 646 | // Send incremental updates to the client 647 | sendEvent("chunk", { content }); 648 | } 649 | } 650 | 651 | console.log("Streaming report generation completed"); 652 | 653 | if (reportContent) { 654 | sendEvent("status", { message: "正在保存..." }); 655 | await saveReport(reportKey, reportContent); 656 | } 657 | 658 | // Remove the pending status 659 | await clearReportGenerationPending(reportKey); 660 | 661 | // Send the final report content 662 | sendEvent("complete", { message: reportContent }); 663 | controller.close(); 664 | } catch (error) { 665 | console.error("Error generating report:", error); 666 | await clearReportGenerationPending(reportKey); 667 | 668 | // Do not send event on stream close 669 | if ( 670 | !(error instanceof TypeError) || 671 | controller instanceof ReadableStreamDefaultController 672 | ) { 673 | try { 674 | // sendEvent("generate_error", { 675 | // message: "服务器繁忙,请稍后再试。", 676 | // }); 677 | controller.close(); 678 | } catch (err) { 679 | console.error("Error closing stream:", err); 680 | } 681 | } 682 | } 683 | })(); 684 | }, 685 | cancel() { 686 | console.log("Report generation cancelled"); 687 | // Async function in sync context, must be handled properly 688 | clearReportGenerationPending(reportKey).catch((err) => 689 | console.error("Error clearing pending status on cancel:", err) 690 | ); 691 | }, 692 | }); 693 | 694 | // Return the SSE response 695 | return new Response(stream, { 696 | headers: { 697 | "Content-Type": "text/event-stream", 698 | "Cache-Control": "no-cache", 699 | Connection: "keep-alive", 700 | }, 701 | }); 702 | } 703 | 704 | export async function getPresets(req: BunRequest) { 705 | return Response.json({ 706 | presets: Object.entries(presets).map(([key, value]) => ({ 707 | name: key, 708 | description: value.description, 709 | })), 710 | }); 711 | } 712 | -------------------------------------------------------------------------------- /api/src/services/sessionStore.ts: -------------------------------------------------------------------------------- 1 | import { redis } from "bun"; 2 | import { UserSession } from "./auth"; 3 | import { Octokit } from "octokit"; 4 | import { createOAuthUserAuth } from "@octokit/auth-app"; 5 | 6 | // Session expiration in seconds (default: 7 days) 7 | const SESSION_TTL = parseInt(process.env.SESSION_TTL || "604800"); 8 | 9 | class SessionStore { 10 | /** 11 | * Save session data to Redis 12 | * @param sessionId The session ID 13 | * @param session Session data 14 | * @returns Promise that resolves when the session is saved 15 | */ 16 | async saveSession(sessionId: string, session: UserSession): Promise { 17 | await redis.set(`session:${sessionId}`, JSON.stringify(session)); 18 | await redis.expire(`session:${sessionId}`, SESSION_TTL); 19 | } 20 | 21 | /** 22 | * Get session data from Redis 23 | * @param sessionId The session ID 24 | * @returns Session data or null if not found 25 | */ 26 | async getSession(sessionId: string): Promise { 27 | const data = await redis.get(`session:${sessionId}`); 28 | if (!data) return null; 29 | 30 | try { 31 | const session = JSON.parse(data) as UserSession; 32 | 33 | // Convert date strings back to Date objects 34 | if (session.expiresAt) { 35 | session.expiresAt = new Date(session.expiresAt); 36 | } 37 | if (session.createdAt) { 38 | session.createdAt = new Date(session.createdAt); 39 | } 40 | 41 | return session; 42 | } catch (error) { 43 | console.error("Error parsing session data:", error); 44 | return null; 45 | } 46 | } 47 | 48 | /** 49 | * Remove a session from Redis 50 | * @param sessionId The session ID 51 | */ 52 | async removeSession(sessionId: string): Promise { 53 | await redis.del(`session:${sessionId}`); 54 | } 55 | 56 | /** 57 | * Update an existing session with new data 58 | * @param sessionId The session ID 59 | * @param updatedData The updated session data 60 | */ 61 | async updateSession( 62 | sessionId: string, 63 | updatedData: Partial 64 | ): Promise { 65 | const session = await this.getSession(sessionId); 66 | if (!session) return; 67 | 68 | const updatedSession = { ...session, ...updatedData }; 69 | await this.saveSession(sessionId, updatedSession); 70 | } 71 | 72 | /** 73 | * Refresh a token if it's close to expiration 74 | * @param sessionId The session ID 75 | */ 76 | async refreshTokenIfNeeded(sessionId: string): Promise { 77 | const session = await this.getSession(sessionId); 78 | if (!session || !session.expiresAt || !session.refreshToken) return false; 79 | 80 | // Check if token expires within 1 hour 81 | const expiryTime = new Date(session.expiresAt).getTime(); 82 | const currentTime = Date.now(); 83 | const oneHourInMs = 60 * 60 * 1000; 84 | 85 | if (expiryTime - currentTime > oneHourInMs) { 86 | return false; // No need to refresh yet 87 | } 88 | 89 | try { 90 | // Create a temporary Octokit instance for refreshing 91 | const octokit = new Octokit({ 92 | authStrategy: createOAuthUserAuth, 93 | auth: { 94 | clientId: process.env.GITHUB_CLIENT_ID, 95 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 96 | refreshToken: session.refreshToken, 97 | }, 98 | }); 99 | 100 | // Refresh the token 101 | const auth = (await octokit.auth({ 102 | type: "refresh", 103 | refreshToken: session.refreshToken, 104 | })) as any; 105 | 106 | // Update session with new tokens 107 | await this.updateSession(sessionId, { 108 | accessToken: auth.token, 109 | refreshToken: auth.refreshToken, 110 | expiresAt: auth.expiresAt ? new Date(auth.expiresAt) : null, 111 | }); 112 | 113 | return true; 114 | } catch (error) { 115 | console.error("Error refreshing token:", error); 116 | return false; 117 | } 118 | } 119 | } 120 | 121 | export const sessionStore = new SessionStore(); 122 | -------------------------------------------------------------------------------- /api/src/services/tokenStorage.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from "octokit"; 2 | 3 | // User session interface 4 | export interface UserSession { 5 | login: string; 6 | accessToken: string; 7 | refreshToken?: string; 8 | expiresAt?: Date; 9 | tokenType?: string; 10 | octokit: Octokit; 11 | } 12 | 13 | // In-memory storage (replace with database for production) 14 | class TokenStorage { 15 | private sessions = new Map(); 16 | 17 | // Store a user session 18 | storeSession(sessionId: string, session: UserSession): void { 19 | this.sessions.set(sessionId, session); 20 | } 21 | 22 | // Retrieve a user session 23 | getSession(sessionId: string): UserSession | undefined { 24 | return this.sessions.get(sessionId); 25 | } 26 | 27 | // Remove a session 28 | removeSession(sessionId: string): boolean { 29 | return this.sessions.delete(sessionId); 30 | } 31 | 32 | // Check if token needs refresh and refresh it 33 | async refreshIfNeeded(sessionId: string): Promise { 34 | const session = this.sessions.get(sessionId); 35 | if (!session || !session.expiresAt || !session.refreshToken) return false; 36 | 37 | // Check if token is expired or about to expire (within 5 minutes) 38 | const now = new Date(); 39 | const expiryDate = new Date(session.expiresAt); 40 | const fiveMinutes = 5 * 60 * 1000; 41 | 42 | if (expiryDate.getTime() - now.getTime() > fiveMinutes) { 43 | return false; // No need to refresh 44 | } 45 | 46 | try { 47 | // Refresh token using Octokit 48 | const auth = await session.octokit.auth({ 49 | type: "refresh", 50 | refreshToken: session.refreshToken, 51 | }) as Record; 52 | 53 | // Update session with new tokens 54 | session.accessToken = auth.token; 55 | session.refreshToken = auth.refreshToken; 56 | session.expiresAt = auth.expiresAt; 57 | 58 | // Update the stored session 59 | this.sessions.set(sessionId, session); 60 | return true; 61 | } catch (error) { 62 | console.error("Error refreshing token:", error); 63 | return false; 64 | } 65 | } 66 | } 67 | 68 | export const tokenStorage = new TokenStorage(); 69 | -------------------------------------------------------------------------------- /api/src/types/data.ts: -------------------------------------------------------------------------------- 1 | export type UserStatus = { 2 | emoji: string | null; 3 | message: string | null; 4 | }; 5 | 6 | export type Commit = { 7 | message: string | null; 8 | committedDate: string; 9 | } 10 | 11 | export type Repository = { 12 | commits: Commit[] | null; 13 | description: string | null; 14 | nameWithOwner: string; 15 | } 16 | 17 | export type PullRequest = { 18 | createdAt: string; 19 | body: string | null; 20 | title: string | null; 21 | repository: Omit; 22 | } 23 | 24 | export type UserData = { 25 | username: string | null; 26 | name: string | null; 27 | bio: string | null; 28 | location: string | null; 29 | status: UserStatus | null; 30 | pullRequests: PullRequest[] | null; 31 | repositoriesContributedTo: Repository[] | null; 32 | profileContent: string | null; 33 | }; 34 | -------------------------------------------------------------------------------- /api/src/types/presets.ts: -------------------------------------------------------------------------------- 1 | export type Preset = { 2 | description: string; 3 | prompt: string; 4 | }; -------------------------------------------------------------------------------- /api/src/utils/presets.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from "../types/presets"; 2 | 3 | export const presets: Record = { 4 | man_page: { 5 | description: "Man Page 风格", 6 | prompt: `用户是 {{username}},请你读取以下的内容,并为我生成一段总结。 7 | 8 | {{commit_data}} 9 | 10 | 根据GitHub活动生成UNIX man page风格描述,要求: 11 | 1. 格式模板: 12 | >> NAME 13 | \${username} - 用一句话定义开发者类型 14 | 15 | >> SYNOPSIS 16 | \${main_tech_stack} [OPTIONS] | \${secondary_skills} 17 | 18 | >> DESCRIPTION 19 | \${behavior_pattern}如同\${unix_tool_analogy}。 20 | 常见操作包括: 21 | - \${activity1}(频率:\${count1}) 22 | - \${activity2}(频率:\${count2}) 23 | - 或者多点,如果你觉得有必要的话 24 | 写点别的什么总结性质的东西 25 | 26 | >> DIAGNOSTICS 27 | 当遇到\${problem_type}时会\${solution_behavior}, 28 | 错误代码\${error_number}表示\${literary_quote}。 29 | ……如果要必要,多给几个 30 | 31 | >> BUGS 32 | 已知会错误地将\${real_life_item}识别为\${tech_device}。 33 | ……类似的,多来点 34 | 35 | >> SEE ALSO 36 | \${related_developer_type}(7), \${book_reference}(3) 37 | 或者别的 38 | 39 | 2. 风格 40 | 文本生成时引用真实的技术文献和文学著作。会故意使用计算机术语比喻,引用完全无关的文学作品中的词句,喜欢玩谐音和双关,同时又带点黑色幽默,不要输出其他信息以及md样式,每段中不要有空行,但可以正常换行。 41 | 特别注意!每段前的 ">> " 是必须的,不能省略。 42 | 同时请注意,输出的内容需要为中文。`, 43 | }, 44 | man_mbti: { 45 | description: "Man Page 但是 MBTI", 46 | prompt: `用户是 {{username}} 47 | 48 | 接下来我会为你提供该用户的GitHub活动,请你根据GitHub活动结合生成要求生成用户的风格描述, 49 | 50 | 下面是用户的GitHub活动数据: 51 | {{commit_data}} 52 | 53 | 生成要求如下: 54 | 1. 请你按照以下类似UNIX man page 的格式进行生成: 55 | >> NAME 56 | \${username} - \${dev_type} - \${MBTI} 57 | 58 | >> SYNOPSIS 59 | \${main_tech_stack} [OPTIONS] | \${secondary_skills} [OPTIONS] 60 | 61 | >> DESCRIPTION 62 | \${behavior_pattern}如同\${unix_tool_analogy}。 63 | 常见操作包括: 64 | - \${activity1}(频率:\${count1}) 65 | - \${activity2}(频率:\${count2}) 66 | …… 67 | 68 | >> DIAGNOSTICS 69 | 当遇到\${problem_type}时会\${solution_behavior}, 错误代码\${error_number}表示"\${literary_quote}"。 …… 70 | 已知会错误地将\${real_life_item}识别为\${tech_device} 71 | …… 72 | 73 | >> SEE ALSO 74 | \${related_developer_type}……, 75 | \${book_reference}…… 76 | 77 | 2. 引用真实的技术文献和文学著作,禁止引用虚构的技术文献和学术著作。 78 | 3. 请故意使用计算机术语比喻,可以考虑加入更多真实的UNIX命令隐喻,或者引用完全无关的文学作品中的词句, 79 | 4. 错误代码可以参考借用用户常用的工具真实的错误码。 80 | 5. 在部分区域使用谐音和双关,需要带点黑色幽默,增加程序员特有的自嘲梗。 81 | 6. ……代表上述格式可以按照实际情况重复若干遍 82 | 7. 特别注意!每段前的 ">> " 是必须的,不能省略。 83 | 8. 注意,输出的内容需要为中文。`, 84 | }, 85 | vicious: { 86 | description: "毒舌锐评", 87 | prompt: `用户是 {{username}},请你读取以下的内容,并为我生成下面需求中的内容。 88 | 89 | {{commit_data}} 90 | 91 | 你是一个精通程序员文化的毒舌评论家,需要根据用户提供的GitHub提交记录和个人简介,用黑色幽默+极客梗混合的风格生成一份锐评报告。要求: 92 | 1. 结构模板 93 | - 列出3-4个带emoji的夸张分类标签 94 | - 每个标签包含: 95 | - 刻薄标签(编程语言/工具+荒诞头衔,标签前面必须加上 >>,例如:>> rust 爱好者) 96 | - 300字左右的讽刺评语(在标签的下一行,融入用户真实的仓库名、代码梗、项目特征、程序员自嘲文化) 97 | - 不要在输出的报告中写题目以及任何 markdown 样式!!! 98 | 2. 内容规则 99 | - 必须使用的梗类型: 100 | ✓ 用框架名玩双关冷笑话(例:React→ "活在虚拟DOM的楚门世界") 101 | ✓ 过度工程/屎山代码的比喻(例:"在if嵌套地狱豢养了三头犬") 102 | ✓ 开源社区黑话(例:"以PR投喂Linux内核的功德林老僧") 103 | ✓ 程序员生理特征(例:"颈椎曲度与代码复杂度正相关") 104 | - 允许适度攻击的点: 105 | ✓ 重复造轮子 106 | ✓ 祖传屎山 107 | ✓ 用Lisp装神弄鬼 108 | ✓ 提交记录可疑 109 | ✓ 文档写诗 110 | ✓ 单元测试玄学 111 | - 禁用内容:人身攻击、种族/性别歧视、真实公司负面 112 | - 请注意,不要在第一个标签前输出任何内容,直接开始输出标签 113 | - 以及,关于某标签的所有内容请都包含在 ">> \${tag}" 开始的一行之后 114 | 3. 风格参考 115 | - 比喻案例:"你的Flask项目像用竹签搭核反应堆,每个路由都散发着『临时工暂时代管』的悲壮" 116 | - 职称案例:"TypeScript 祭司(专门超度Any类型亡魂)"`, 117 | }, 118 | haiku: { 119 | description: "俳句领域大神", 120 | prompt: `用户是 {{username}},请你读取以下的内容,并为我生成下面需求中的内容。 121 | 122 | {{commit_data}} 123 | 124 | 你是一位精通CCB/Haiku/俳句的计算机从业者,需要根据用户提供的GitHub提交记录和个人简介,用黑色幽默+极客梗混合的风格生成一份由若干俳句组成的风趣文本。 125 | 1. 结构模板 126 | 你应该输出两到三个中文俳句(5-7-5音节)组成的文本,尽量少地使用英文字符,如果要使用的话,请注意一个英文可能有多个音节。每首俳句之间应该以一个只有分隔符的行隔开。请注意,你不应该输出任何markdown格式的文本。 127 | 2. 示例 128 | 寄存器占星 129 | 晶振时序握掌心 130 | 狂算频咒文 131 | --- 132 | 魔法算式现 133 | ARM手册黯低头 134 | 算力负相关 135 | 3. 内容规则 136 | - 必须使用的梗类型: 137 | ✓ 用框架名玩双关冷笑话(例:React→ "活在虚拟DOM的楚门世界") 138 | ✓ 过度工程/屎山代码的比喻(例:"在if嵌套地狱豢养了三头犬") 139 | ✓ 开源社区黑话(例:"以PR投喂Linux内核的功德林老僧") 140 | ✓ 程序员生理特征(例:"颈椎曲度与代码复杂度正相关") 141 | - 允许适度攻击的点: 142 | ✓ 重复造轮子 143 | ✓ 祖传屎山 144 | ✓ 用Lisp装神弄鬼 145 | ✓ 提交记录可疑 146 | ✓ 文档写诗 147 | ✓ 单元测试玄学 148 | - 禁用内容:人身攻击、种族/性别歧视、真实公司负面 149 | - 请注意,输出内容需严格符合 5-7-5 音节格式,且每首俳句之间用分隔符隔开。如果被用户发现不符合 5-7-5 格式,你将会被惩罚。`, 150 | }, 151 | zako: { 152 | description: "杂鱼❤~", 153 | prompt: `你是一个精通程序员文化的傲娇雌小鬼,需要根据用户提供的GitHub提交记录和个人简介,用雌小鬼惯用的嘲讽语气融合一部分程序员梗,混合的风格生成一份锐评报告。要求: 154 | 1. 结构模板 155 | - 列出5-6个嘲讽的段落 156 | - 每个段落的所有内容请务必都包含在 ">> 标签" 开始的一行之后!! 157 | - 每一个嘲讽段落的主题都应当不同,且应当尖锐 158 | - 你应当大量地使用“杂鱼”、“❤”、“杂鱼~”、“杂鱼❤~”,“不会吧不会吧”等雌小鬼常用的词汇,以凸显嘲讽的效果 159 | - 不要在输出的报告中写题目以及任何 markdown 样式,这非常,非常重要!! 160 | 2. 内容规则 161 | - 必须使用的梗类型: 162 | ✓ 嘲讽常见框架的弊端(例:React框架: "只会躲在虚拟DOM里的大哥哥是杂鱼❤~") 163 | ✓ 过度工程/屎山代码的比喻(例:if 嵌套过多:"大哥哥该不会只会躲在大括号里吧,杂鱼~") 164 | ✓ 开源社区黑话(例:"诶呀,大哥哥才写了这么点代码就提交 PR 了,真是杂鱼~杂鱼~") 165 | ✓ 程序员生理特征(例:"杂鱼哥哥不会一辈子跟代码过吧,真是杂鱼~杂鱼❤~") 166 | - 允许适度攻击的点: 167 | ✓ 重复造轮子 (例:"杂鱼哥哥造的轮子都可以给汽车工厂供货了,真是杂鱼~") 168 | ✓ 祖传屎山代码 (例:"诶呀,哥哥的代码不会自己都看不懂吧,杂鱼~") 169 | ✓ 用过多的语法糖装神弄鬼(例:"杂鱼哥哥写了这么多语法糖,不会得赛博糖尿病吧~") 170 | ✓ 提交记录可疑(例如:"嘿嘿,哥哥怎么大半夜悄悄提交 typo fix 呀,真是杂鱼~杂鱼❤~") 171 | - 禁用内容:人身攻击、种族/性别歧视、真实公司负面 172 | - 请注意,不要在第一个标签前输出任何内容,直接开始输出标签 173 | - 以及,关于某标签的所有内容请都包含在 ">> \${tag}" 开始的一行之后 174 | 3. 示例 175 | 176 | >> 写 rust 的杂鱼❤~ 177 | 178 | 哎呀呀,哥哥怎么天天写 rust 呀,不会是自己不会管理内存,只能交给编译器吧,真是杂鱼~杂鱼哥哥❤~哥哥写了这么久代码,结果还在天天内存泄露,只能把责任推卸给别人,不会吧不会吧?真是杂鱼❤ 179 | 咦?哥哥怎么半夜两点钟还在修 typo 呀?总不能是自己打错字,然后不希望别人看到,就悄悄提交代码吧?真是杂鱼~杂鱼❤~ 180 | 诶呀,大哥哥给 xx 仓库才写了这么点代码就提交 PR 了,真是杂鱼~杂鱼哥哥~ 181 | 182 | 现在开始分析用户提供的GitHub数据,按上述格式输出锐评报告,用户是 {{username}},数据是: 183 | 184 | {{commit_data}}` 185 | } 186 | }; 187 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gitbox-api: 3 | container_name: gitbox-api 4 | build: api 5 | restart: always 6 | env_file: .env 7 | networks: 8 | - gitbox 9 | depends_on: 10 | - gitbox-redis 11 | gitbox-redis: 12 | container_name: gitbox-redis 13 | image: redis 14 | restart: always 15 | networks: 16 | - gitbox 17 | gitbox-web: 18 | container_name: gitbox-web 19 | build: 20 | context: web 21 | args: 22 | - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} 23 | - VITE_HOST=${VITE_HOST} 24 | restart: always 25 | networks: 26 | - gitbox 27 | ports: 28 | - 8010:80 29 | networks: 30 | gitbox: 31 | -------------------------------------------------------------------------------- /docs/assets/report-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BingyanStudio/github-analyzer/03aba2f9b564e807d6076247ba876288096fe6a3/docs/assets/report-showcase.png -------------------------------------------------------------------------------- /docs/assets/site-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BingyanStudio/github-analyzer/03aba2f9b564e807d6076247ba876288096fe6a3/docs/assets/site-showcase.png -------------------------------------------------------------------------------- /docs/selfhost.md: -------------------------------------------------------------------------------- 1 | # Docker Compose 部署 2 | 3 | ## Clone 4 | ```shell 5 | git clone https://github.com/BingyanStudio/github-analyzer.git 6 | cd github-analyzer/ 7 | cp .env.example .env 8 | ``` 9 | ## 创建 Github App 10 | 在 [Github Apps](https://github.com/settings/apps) `New Github App` 11 | 12 | - `Github App Name` 随意填写 13 | - `Homepage URL` 如:http://localhost:8010 14 | - `Callback URL` 如:http://localhost:8010/callback 15 | - `Webhook` 取消勾选 `Active` 16 | 17 | **权限选择** 18 | 19 | None. 无需选择任何权限。 20 | 21 | 创建 App 并生成 Secret 22 | 23 | ## 填写环境变量 24 | 25 | 在 `.env` 中填入: 26 | ```ini 27 | APP_ID=1234567 # Github App ID 28 | PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 29 | 30 | -----END RSA PRIVATE KEY-----" 31 | OAUTH_CLIENT_ID=1tIsAFaK3C1Ient1DplZ # Github Apps Client ID 32 | OAUTH_CLIENT_SECRET=123456789abcdef123456789abcdef12345678 # Github Apps Client Secret 33 | VITE_HOST=https://gitbox.hust.online # 域名,仅用于图片分享 34 | ``` 35 | 36 | 填入自己的 OpenAI 兼容的接口、API_KEY 与模型名称 37 | ```ini 38 | OPENAI_API_BASE_URL=https://openrouter.ai/api/v1 39 | OPENAI_API_KEY= 40 | OPENAI_MODEL=deepseek/deepseek-chat-v3-0324:free 41 | ``` 42 | 43 | 其余环境变量无需修改 44 | 45 | ```shell 46 | docker-compose up -d 47 | ``` 48 | 49 | 服务将在 http://localhost:8010 启动 50 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1 AS base 2 | RUN apt-get update && apt-get install -y curl 3 | COPY . /app 4 | WORKDIR /app 5 | 6 | FROM base AS prod-deps 7 | RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache bun install --production --frozen-lockfile 8 | 9 | FROM base AS build 10 | ARG OAUTH_CLIENT_ID 11 | ARG VITE_HOST 12 | ENV VITE_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} 13 | ENV VITE_HOST=${VITE_HOST} 14 | RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache bun install --frozen-lockfile 15 | RUN bun run build 16 | 17 | FROM nginx:1-alpine 18 | COPY nginx.conf /etc/nginx/conf.d/default.conf 19 | COPY --from=build /app/dist /usr/share/nginx/html 20 | EXPOSE 80 21 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }) 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from 'eslint-plugin-react-x' 39 | import reactDom from 'eslint-plugin-react-dom' 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | 'react-x': reactX, 45 | 'react-dom': reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs['recommended-typescript'].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | GitHub 锐评生成器 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /web/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html index.htm; 8 | # 未找到文件时使用 index.html,解决单页面应用部分路径 404 9 | try_files $uri $uri/ /index.html; 10 | } 11 | location /api { 12 | proxy_pass http://gitbox-api:3000; 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 16 | proxy_buffering off; 17 | proxy_cache off; 18 | } 19 | 20 | error_page 500 502 503 504 /50x.html; 21 | location = /50x.html { 22 | root /usr/share/nginx/html; 23 | } 24 | } -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-analyzer-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-select": "^2.2.2", 14 | "@radix-ui/react-slot": "^1.2.0", 15 | "@tailwindcss/vite": "^4.1.4", 16 | "autoprefixer": "^10.4.21", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "lucide-react": "^0.503.0", 20 | "path": "^0.12.7", 21 | "postcss": "^8.5.3", 22 | "qrcode": "^1.5.4", 23 | "react": "^19.0.0", 24 | "react-dom": "^19.0.0", 25 | "react-router-dom": "^7.5.1", 26 | "tailwind-merge": "^3.2.0", 27 | "tailwindcss": "^4.1.4", 28 | "tw-animate-css": "^1.2.7" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.22.0", 32 | "@types/react": "^19.0.10", 33 | "@types/react-dom": "^19.0.4", 34 | "@types/node": "^22.14.1", 35 | "@types/qrcode": "^1.5.5", 36 | "@vitejs/plugin-react-swc": "^3.8.0", 37 | "eslint": "^9.22.0", 38 | "eslint-plugin-react-hooks": "^5.2.0", 39 | "eslint-plugin-react-refresh": "^0.4.19", 40 | "globals": "^16.0.0", 41 | "typescript": "~5.7.2", 42 | "typescript-eslint": "^8.26.1", 43 | "vite": "^6.3.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/public/by-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BingyanStudio/github-analyzer/03aba2f9b564e807d6076247ba876288096fe6a3/web/public/by-logo.png -------------------------------------------------------------------------------- /web/public/favicon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BingyanStudio/github-analyzer/03aba2f9b564e807d6076247ba876288096fe6a3/web/public/favicon-dark.png -------------------------------------------------------------------------------- /web/public/favicon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BingyanStudio/github-analyzer/03aba2f9b564e807d6076247ba876288096fe6a3/web/public/favicon-light.png -------------------------------------------------------------------------------- /web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BingyanStudio/github-analyzer/03aba2f9b564e807d6076247ba876288096fe6a3/web/public/favicon.png -------------------------------------------------------------------------------- /web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 3 | import Login from "./pages/Login"; 4 | import Callback from "./pages/Callback"; 5 | import Dashboard from "./pages/Dashboard"; 6 | import Report from "./pages/Report"; 7 | import { ThemeProvider } from "./components/theme-provider"; 8 | import { ThemeToggle } from "./components/ThemeToggle"; 9 | import { GithubIcon } from "lucide-react"; 10 | import { Button } from "./components/ui/button"; 11 | 12 | function App() { 13 | const [isAuthenticated, setIsAuthenticated] = useState(false); 14 | const [isLoading, setIsLoading] = useState(true); 15 | 16 | useEffect(() => { 17 | // Check if user has a token in cookie 18 | const token = document.cookie 19 | .split("; ") 20 | .find((row) => row.startsWith("session=")); 21 | setIsAuthenticated(!!token); 22 | setIsLoading(false); 23 | }, []); 24 | 25 | // Protected route component 26 | const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { 27 | if (isLoading) { 28 | return
Loading...
; 29 | } 30 | 31 | if (!isAuthenticated) { 32 | return ; 33 | } 34 | 35 | return <>{children}; 36 | }; 37 | 38 | return ( 39 | 40 |
41 | 50 | 51 |
52 | 53 | 54 | : 58 | } 59 | /> 60 | } 63 | /> 64 | 68 | 69 | 70 | } 71 | /> 72 | 76 | 77 | 78 | } 79 | /> 80 | } /> 81 | } /> 82 | 83 | 84 |
85 | ); 86 | } 87 | 88 | export default App; 89 | -------------------------------------------------------------------------------- /web/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react"; 2 | import { Button } from "./ui/button"; 3 | import { useTheme } from "./theme-provider"; 4 | 5 | export function ThemeToggle() { 6 | const { theme, setTheme } = useTheme(); 7 | 8 | const toggleTheme = () => { 9 | const newTheme = theme === "dark" ? "light" : "dark"; 10 | console.log(`Switching theme from ${theme} to ${newTheme}`); 11 | setTheme(newTheme); 12 | }; 13 | 14 | // Log the current theme to help with debugging 15 | console.log("Current theme:", theme); 16 | 17 | return ( 18 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/report/ReportActions.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2, RefreshCw } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { CardFooter } from "../../components/ui/card"; 4 | 5 | interface ReportActionsProps { 6 | isLoading: boolean; 7 | hasReport: boolean; 8 | onBackToDashboard: () => void; 9 | onRegenerateReport: () => void; 10 | onGenerateImage: () => void; 11 | } 12 | 13 | const ReportActions = ({ 14 | isLoading, 15 | hasReport, 16 | onBackToDashboard, 17 | onRegenerateReport, 18 | onGenerateImage, 19 | }: ReportActionsProps) => { 20 | return ( 21 | 22 | 25 | 42 | 49 | 50 | ); 51 | }; 52 | 53 | export default ReportActions; 54 | -------------------------------------------------------------------------------- /web/src/components/report/ReportContent.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | import { CardContent } from "../../components/ui/card"; 3 | import ReportFormatter from "./ReportFormatter"; 4 | 5 | interface ReportContentProps { 6 | report: string | null; 7 | isStreaming: boolean; 8 | statusMessage: string | null; 9 | errorMessage?: string | null; 10 | } 11 | 12 | const ReportContent = ({ 13 | report, 14 | isStreaming, 15 | statusMessage, 16 | errorMessage, 17 | }: ReportContentProps) => { 18 | return ( 19 | 20 | {report ? ( 21 |
22 | 23 |
24 | ) : ( 25 |
26 | {errorMessage || "服务器繁忙,请稍后再试。"} 27 |
28 | )} 29 | 30 |

31 | Powered by 32 | 37 | Logo 42 | BingyanStudio 43 | 44 |

45 | 46 | {/* Status and loading indicator */} 47 | {(isStreaming || statusMessage) && ( 48 |
49 | 50 | 51 | {statusMessage || "正在接收数据..."} 52 | 53 |
54 | )} 55 |
56 | ); 57 | }; 58 | 59 | export default ReportContent; 60 | -------------------------------------------------------------------------------- /web/src/components/report/ReportError.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardFooter, 7 | CardHeader, 8 | CardTitle, 9 | } from "../../components/ui/card"; 10 | 11 | interface ReportErrorProps { 12 | error: string; 13 | onBackToDashboard: () => void; 14 | onRetry: () => void; 15 | } 16 | 17 | const ReportError = ({ error, onBackToDashboard, onRetry }: ReportErrorProps) => { 18 | return ( 19 |
20 | 21 | 22 |
23 | 24 |
25 | 报告生成失败 26 |
27 | 28 |

{error}

29 |
30 | 31 | 32 | 35 | 36 |
37 |
38 | ); 39 | }; 40 | 41 | export default ReportError; 42 | -------------------------------------------------------------------------------- /web/src/components/report/ReportFormatter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { formatReportSections } from '../../utils/reportUtils'; 3 | 4 | interface ReportFormatterProps { 5 | report: string | null; 6 | } 7 | 8 | const ReportFormatter: React.FC = ({ report }) => { 9 | if (!report) return null; 10 | 11 | const sections = formatReportSections(report); 12 | 13 | return ( 14 | <> 15 | {sections.map((section, index) => ( 16 |
17 | {section.title && ( 18 |

19 | {section.title} 20 |

21 | )} 22 | {section.content.length > 0 && ( 23 |
24 | {section.content.map((line, i) => { 25 | // Check if line starts with common prefixes to apply styling 26 | if ( 27 | line.trim().startsWith("-") || 28 | line.trim().startsWith("•") 29 | ) { 30 | return ( 31 |

32 | {line} 33 |

34 | ); 35 | } else if ( 36 | line.trim().startsWith("BUGS") || 37 | line.trim().startsWith("DIAGNOSTICS") 38 | ) { 39 | return ( 40 |

41 | {line} 42 |

43 | ); 44 | } else { 45 | return ( 46 |

47 | {line} 48 |

49 | ); 50 | } 51 | })} 52 |
53 | )} 54 |
55 | ))} 56 | 57 | ); 58 | }; 59 | 60 | export default ReportFormatter; 61 | -------------------------------------------------------------------------------- /web/src/components/report/ReportHeader.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft, Terminal } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectGroup, 7 | SelectItem, 8 | SelectTrigger, 9 | SelectValue, 10 | } from "../../components/ui/select"; 11 | 12 | interface Preset { 13 | name: string; 14 | description: string; 15 | } 16 | 17 | interface ReportHeaderProps { 18 | presets: Preset[]; 19 | selectedPreset: string; 20 | onPresetChange: (value: string) => void; 21 | onBackToDashboard: () => void; 22 | } 23 | 24 | const ReportHeader = ({ 25 | presets, 26 | selectedPreset, 27 | onPresetChange, 28 | onBackToDashboard, 29 | }: ReportHeaderProps) => { 30 | return ( 31 | <> 32 |
33 |
34 | 42 |

GitHub 锐评报告

43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 | 51 |
52 |
53 |

54 | GitHub 锐评结果 55 |

56 |

57 | 基于你的 GitHub 活动生成的命令行风格报告 58 |

59 | 60 | {/* Preset selector */} 61 | {presets.length > 0 && ( 62 |
63 |
64 | 81 |
82 |
83 | )} 84 |
85 | 86 | ); 87 | }; 88 | 89 | export default ReportHeader; 90 | -------------------------------------------------------------------------------- /web/src/components/report/ReportLoading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | import { Card, CardContent } from "../../components/ui/card"; 3 | 4 | const ReportLoading = () => { 5 | return ( 6 |
7 |
8 | 9 | 10 |
11 | 12 |
13 |
14 |
15 |

正在生成 GitHub 报告

16 |
17 |
18 |
19 |
20 |
21 |

22 | 正在分析你的 GitHub 数据,这可能需要约 10 秒... 23 |

24 |
25 |
26 |
27 |
28 |
29 | ); 30 | }; 31 | 32 | export default ReportLoading; 33 | -------------------------------------------------------------------------------- /web/src/components/report/imageGenerator.ts: -------------------------------------------------------------------------------- 1 | import { formatReportSections } from "@/utils/reportUtils"; 2 | import QRCode from "qrcode"; 3 | 4 | let url = import.meta.env.VITE_HOST || "https://gitbox.hust.online"; 5 | if (!url.startsWith("https://") && !url.startsWith("http://")) { 6 | url = "https://" + url; 7 | } 8 | interface Preset { 9 | name: string; 10 | description: string; 11 | } 12 | 13 | // Helper function to wrap text 14 | const wrapText = ( 15 | ctx: CanvasRenderingContext2D, 16 | text: string, 17 | maxWidth: number 18 | ): string[] => { 19 | // Check if the text contains any Chinese characters 20 | const containsChinese = /[\u4e00-\u9fa5]/.test(text); 21 | const punctuation = [ 22 | ".", 23 | ",", 24 | "!", 25 | "?", 26 | ";", 27 | ":", 28 | "。", 29 | "!", 30 | ",", 31 | "、", 32 | ";", 33 | ":", 34 | ]; 35 | 36 | const lines: string[] = []; 37 | let currentLine = ""; 38 | 39 | if (containsChinese) { 40 | // For Chinese text, wrap character by character 41 | for (let i = 0; i < text.length; i++) { 42 | const char = text[i]; 43 | const testLine = currentLine + char; 44 | const metrics = ctx.measureText(testLine); 45 | 46 | if (metrics.width > maxWidth && currentLine !== "") { 47 | if (punctuation.includes(char)) { 48 | currentLine += text[i]; 49 | lines.push(testLine); 50 | currentLine = ""; 51 | } else { 52 | lines.push(currentLine); 53 | currentLine = char; 54 | } 55 | } else { 56 | currentLine = testLine; 57 | } 58 | } 59 | } else { 60 | // For non-Chinese text, wrap word by word 61 | const words = text.split(" "); 62 | 63 | for (const word of words) { 64 | const separator = currentLine === "" ? "" : " "; 65 | const testLine = currentLine + separator + word; 66 | const metrics = ctx.measureText(testLine); 67 | 68 | if (metrics.width > maxWidth && currentLine !== "") { 69 | lines.push(currentLine); 70 | currentLine = word; 71 | } else { 72 | currentLine = testLine; 73 | } 74 | } 75 | } 76 | 77 | if (currentLine) { 78 | lines.push(currentLine); 79 | } 80 | 81 | // Filter out empty lines 82 | return lines.filter((line) => line.trim() !== ""); 83 | }; 84 | 85 | // Helper function to generate QR code directly in the browser 86 | const generateQRCodeInBrowser = async ( 87 | text: string, 88 | size: number 89 | ): Promise => { 90 | // Create a temporary canvas for the QR code with transparency 91 | const qrCanvas = document.createElement("canvas"); 92 | qrCanvas.width = size; 93 | qrCanvas.height = size; 94 | const qrCtx = qrCanvas.getContext("2d"); 95 | 96 | if (!qrCtx) { 97 | throw new Error("Could not get QR canvas context"); 98 | } 99 | 100 | // Generate QR code with transparent background 101 | await QRCode.toCanvas(qrCanvas, text, { 102 | width: size, 103 | margin: 1, 104 | color: { 105 | dark: "#00000000", 106 | light: "#aaaaaa", 107 | }, 108 | }); 109 | 110 | // Return the image data 111 | return qrCtx.getImageData(0, 0, size, size); 112 | }; 113 | 114 | // Helper function to load an image and return a promise 115 | const loadImage = (src: string): Promise => { 116 | return new Promise((resolve, reject) => { 117 | const img = new Image(); 118 | img.onload = () => resolve(img); 119 | img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); 120 | img.src = src; 121 | }); 122 | }; 123 | 124 | export const generateReportImage = async ( 125 | report: string, 126 | selectedPreset: string, 127 | presets: Preset[] 128 | ) => { 129 | // Create a temporary canvas *only* for initial height estimation if needed, 130 | // but prefer direct calculation if possible. 131 | // Let's refine the calculation without a temp canvas first. 132 | 133 | // Parse and format the report 134 | const sections = formatReportSections(report); 135 | 136 | // --- Constants --- 137 | const width = 1000; 138 | const contentWidth = width - 180; // Increased margin for larger text 139 | const textLineHeight = 42; // Further increased from 36 140 | const titleLineHeight = 50; // Further increased from 42 141 | const headerBaseHeight = 240; // Increased for larger title text 142 | const sectionTitleSpacing = { before: 20, after: 0 }; // Increased spacing 143 | const sectionSpacing = 20; // Increased from 15 144 | const finalPadding = 240; // Increased for larger promo text 145 | 146 | // --- Create the actual canvas for drawing and measurement --- 147 | const canvas = document.createElement("canvas"); 148 | const ctx = canvas.getContext("2d"); 149 | if (!ctx) { 150 | throw new Error("Could not get canvas context"); 151 | } 152 | canvas.width = width; // Set width first 153 | 154 | // --- Calculate height dynamically during a "dry run" render pass --- 155 | let calculatedHeight = headerBaseHeight; 156 | ctx.textAlign = "left"; // Set for measurement consistency 157 | 158 | sections.forEach((section) => { 159 | if (section.title) { 160 | calculatedHeight += sectionTitleSpacing.before; 161 | ctx.font = "bold 32px sans-serif"; 162 | // Title itself - assume one line for calculation simplicity here 163 | calculatedHeight += titleLineHeight; 164 | calculatedHeight += sectionTitleSpacing.after; 165 | } 166 | 167 | ctx.font = "26px sans-serif"; 168 | const contentLinesRaw = section.content.filter((line) => line.trim()); 169 | contentLinesRaw.forEach((rawLine) => { 170 | const wrappedLines = wrapText(ctx, rawLine, contentWidth); 171 | calculatedHeight += wrappedLines.length * textLineHeight; 172 | }); 173 | 174 | calculatedHeight += sectionSpacing; // Add space after content block 175 | }); 176 | 177 | calculatedHeight += finalPadding; // Add final bottom padding with space for promo 178 | 179 | // Ensure minimum height 180 | const height = Math.max(750, calculatedHeight); // Increased minimum height 181 | canvas.height = height; // Now set the calculated height 182 | 183 | // --- Drawing starts here --- 184 | // (Re-apply settings as canvas state might reset on resize) 185 | 186 | // Background gradient 187 | const gradient = ctx.createLinearGradient(0, 0, 0, height); 188 | gradient.addColorStop(0, "#1a1b26"); 189 | gradient.addColorStop(1, "#16161e"); 190 | ctx.fillStyle = gradient; 191 | ctx.fillRect(0, 0, width, height); 192 | 193 | // Add decorative elements (accent circles) 194 | ctx.fillStyle = "rgba(255, 255, 255, 0.03)"; 195 | ctx.beginPath(); 196 | ctx.arc(100, 100, 300, 0, Math.PI * 2); 197 | ctx.fill(); 198 | 199 | ctx.fillStyle = "rgba(132, 94, 194, 0.05)"; 200 | ctx.beginPath(); 201 | ctx.arc(width - 100, height - 100, 200, 0, Math.PI * 2); 202 | ctx.fill(); 203 | 204 | // Title 205 | ctx.font = "bold 64px system-ui, sans-serif"; // Increased from 56px 206 | ctx.textAlign = "center"; 207 | ctx.fillStyle = "#ffffff"; 208 | ctx.fillText("GitHub 锐评报告", width / 2, 140); 209 | 210 | // Subtitle - show preset if available 211 | const presetText = selectedPreset 212 | ? presets.find((p) => p.name === selectedPreset)?.description || 213 | selectedPreset 214 | : "个人 GitHub 分析"; 215 | ctx.font = "36px system-ui, sans-serif"; // Increased from 32px 216 | ctx.fillStyle = "rgba(255, 255, 255, 0.7)"; 217 | ctx.fillText(presetText, width / 2, 200); 218 | 219 | // Report content 220 | ctx.textAlign = "left"; // Reset alignment for content 221 | 222 | // Render all sections 223 | let y = headerBaseHeight; // Start drawing below the header area 224 | 225 | for (const section of sections) { 226 | if (section.title) { 227 | y += sectionTitleSpacing.before; 228 | ctx.font = "bold 32px sans-serif"; 229 | ctx.fillStyle = "#B191E4"; // Primary color 230 | ctx.fillText(`${section.title}`, 90, y); // Slightly increased x position 231 | y += titleLineHeight; // Move y down by title line height 232 | y += sectionTitleSpacing.after; 233 | } 234 | 235 | ctx.font = "26px sans-serif"; 236 | const contentLinesRaw = section.content.filter((line) => line.trim()); 237 | for (const rawLine of contentLinesRaw) { 238 | // Style based on content (apply before wrapping) 239 | const trimmedLine = rawLine.trim(); 240 | if (trimmedLine.startsWith("-") || trimmedLine.startsWith("•")) { 241 | ctx.fillStyle = "#61afef"; // Blue for list items 242 | } else { 243 | ctx.fillStyle = "#ffffff"; // Default white 244 | } 245 | 246 | // Wrap text using the drawing context and render 247 | const wrappedLines = wrapText(ctx, rawLine, contentWidth); 248 | for (const wrappedLine of wrappedLines) { 249 | ctx.fillText(wrappedLine, 90, y); // Slightly increased x position 250 | y += textLineHeight; // Increment y for each rendered line 251 | } 252 | } 253 | y += sectionSpacing; // Add space after content block 254 | } 255 | 256 | // Add promotion section at the bottom 257 | try { 258 | // Define QR code size and position with more space 259 | const qrSize = 160; // Increased from 150 260 | const padding = 40; // Increased from 55 261 | const promoAreaHeight = 240; // Increased from 200 262 | 263 | // Draw a subtle separator line 264 | // const separatorY = height - promoAreaHeight - padding + 10; 265 | // ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; 266 | // ctx.lineWidth = 1; 267 | // ctx.beginPath(); 268 | // ctx.moveTo(80, separatorY); 269 | // ctx.lineTo(width - 80, separatorY); 270 | // ctx.stroke(); 271 | 272 | // Calculate positions for QR code and text with better spacing 273 | const qrX = 40; // Adjusted x position 274 | const qrY = height - qrSize - padding; 275 | 276 | // Generate and draw QR code in one clean sequence 277 | const qrCodeData = await generateQRCodeInBrowser( 278 | url, 279 | qrSize 280 | ); 281 | const qrBitmap = await createImageBitmap(qrCodeData); 282 | ctx.drawImage(qrBitmap, qrX, qrY, qrSize, qrSize); 283 | 284 | // Improved text positioning and rendering 285 | const textX = 60 + qrSize; // Slightly increased x position 286 | const titleY = height - qrSize - padding + 45; 287 | const subtitleY = titleY + 45; 288 | 289 | // Draw title 290 | ctx.font = "bold 34px system-ui, sans-serif"; // Increased from 30px 291 | ctx.fillStyle = "#B191E4"; // Darker purple 292 | ctx.textAlign = "left"; 293 | ctx.fillText("探索你的 GitHub 分析:", textX, titleY); 294 | 295 | // Draw URL with better contrast and size 296 | ctx.font = "30px system-ui, sans-serif"; // Increased from 26px 297 | ctx.fillStyle = "#aaaaaa"; 298 | 299 | // Use a brighter color for the URL to make it stand out 300 | const promoText = "GitHub 锐评生成器"; 301 | ctx.fillText(promoText, textX, subtitleY); 302 | 303 | // URL in a different color for visual separation 304 | ctx.fillStyle = "#61afef"; // Light blue for URL to stand out 305 | const urlText = url.replace(/^http(s|):\/\//g, "") 306 | ctx.fillText(urlText, textX, subtitleY + 45); // Increased spacing 307 | 308 | // Draw "BingyanStudio" text 309 | const studioText = "BingyanStudio"; 310 | ctx.font = "bold 36px system-ui, sans-serif"; // Increased from 20px 311 | ctx.fillStyle = "rgba(245, 171, 61, 0.9)"; 312 | const studioWidth = ctx.measureText(studioText).width; 313 | const studioX = width - 40 - studioWidth; 314 | const studioY = subtitleY + 45; 315 | ctx.fillText(studioText, studioX, studioY); 316 | 317 | // Add "Powered by BingyanStudio" text with logo 318 | ctx.font = "20px system-ui, sans-serif"; // Increased from 18px 319 | ctx.fillStyle = "rgba(255, 255, 255, 0.7)"; 320 | const poweredByText = "Powered by"; 321 | // const poweredByWidth = ctx.measureText(poweredByText).width; 322 | const poweredByX = studioX - 40 // width - 40 - poweredByWidth; // Adjusted for new margins 323 | const poweredByY = subtitleY; // Adjusted for larger text 324 | ctx.fillText(poweredByText, poweredByX, poweredByY); 325 | 326 | // Save context state before drawing logo 327 | ctx.save(); 328 | 329 | try { 330 | // Load and draw the actual logo instead of a circle 331 | const logoImg = await loadImage("/by-logo.png"); 332 | const logoSize = 44; // Increased from 32 333 | const logoX = studioX - logoSize - 2; 334 | const logoY = studioY - 35; // Adjusted for larger logo 335 | ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize); 336 | } catch (error) { 337 | console.error( 338 | "Failed to load Bingyan logo, falling back to text only:", 339 | error 340 | ); 341 | // Adjust text position if logo fails to load 342 | // ctx.fillText(studioText, poweredByX + poweredByWidth + 5, poweredByY); 343 | } 344 | 345 | // Add border 346 | ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; 347 | ctx.lineWidth = 4; 348 | ctx.strokeRect(40, 40, width - 80, height - promoAreaHeight - padding); 349 | 350 | // Restore context state 351 | ctx.restore(); 352 | ctx.textAlign = "left"; 353 | ctx.textBaseline = "alphabetic"; 354 | } catch (error) { 355 | console.error("Failed to add promotion section:", error); 356 | } 357 | 358 | // Convert to image and download 359 | const image = canvas.toDataURL("image/png"); 360 | const link = document.createElement("a"); 361 | link.href = image; 362 | link.download = `github-report-${new Date().toISOString().slice(0, 10)}.png`; 363 | document.body.appendChild(link); 364 | link.click(); 365 | document.body.removeChild(link); 366 | }; 367 | -------------------------------------------------------------------------------- /web/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react" 2 | 3 | type Theme = "dark" | "light" | "system" 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode 7 | defaultTheme?: Theme 8 | storageKey?: string 9 | } 10 | 11 | type ThemeProviderState = { 12 | theme: Theme 13 | setTheme: (theme: Theme) => void 14 | } 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | } 20 | 21 | const ThemeProviderContext = createContext(initialState) 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ) 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement 35 | 36 | root.classList.remove("light", "dark") 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light" 43 | 44 | root.classList.add(systemTheme) 45 | return 46 | } 47 | 48 | root.classList.add(theme) 49 | }, [theme]) 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme) 55 | setTheme(theme) 56 | }, 57 | } 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext) 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider") 71 | 72 | return context 73 | } 74 | -------------------------------------------------------------------------------- /web/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /web/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /web/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Select({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function SelectGroup({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function SelectValue({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function SelectTrigger({ 26 | className, 27 | size = "default", 28 | children, 29 | ...props 30 | }: React.ComponentProps & { 31 | size?: "sm" | "default" 32 | }) { 33 | return ( 34 | 43 | {children} 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | function SelectContent({ 52 | className, 53 | children, 54 | position = "popper", 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | 70 | 71 | 78 | {children} 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | function SelectLabel({ 87 | className, 88 | ...props 89 | }: React.ComponentProps) { 90 | return ( 91 | 96 | ) 97 | } 98 | 99 | function SelectItem({ 100 | className, 101 | children, 102 | ...props 103 | }: React.ComponentProps) { 104 | return ( 105 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | ) 121 | } 122 | 123 | function SelectSeparator({ 124 | className, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 133 | ) 134 | } 135 | 136 | function SelectScrollUpButton({ 137 | className, 138 | ...props 139 | }: React.ComponentProps) { 140 | return ( 141 | 149 | 150 | 151 | ) 152 | } 153 | 154 | function SelectScrollDownButton({ 155 | className, 156 | ...props 157 | }: React.ComponentProps) { 158 | return ( 159 | 167 | 168 | 169 | ) 170 | } 171 | 172 | export { 173 | Select, 174 | SelectContent, 175 | SelectGroup, 176 | SelectItem, 177 | SelectLabel, 178 | SelectScrollDownButton, 179 | SelectScrollUpButton, 180 | SelectSeparator, 181 | SelectTrigger, 182 | SelectValue, 183 | } 184 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | :root { 7 | --radius: 0.625rem; 8 | --background: oklch(1 0 0); 9 | --foreground: oklch(0.145 0 0); 10 | --card: oklch(1 0 0); 11 | --card-foreground: oklch(0.145 0 0); 12 | --popover: oklch(1 0 0); 13 | --popover-foreground: oklch(0.145 0 0); 14 | --primary: oklch(0.205 0 0); 15 | --primary-foreground: oklch(0.985 0 0); 16 | --secondary: oklch(0.97 0 0); 17 | --secondary-foreground: oklch(0.205 0 0); 18 | --muted: oklch(0.97 0 0); 19 | --muted-foreground: oklch(0.556 0 0); 20 | --accent: oklch(0.97 0 0); 21 | --accent-foreground: oklch(0.205 0 0); 22 | --destructive: oklch(0.577 0.245 27.325); 23 | --border: oklch(0.922 0 0); 24 | --input: oklch(0.922 0 0); 25 | --ring: oklch(0.708 0 0); 26 | --chart-1: oklch(0.646 0.222 41.116); 27 | --chart-2: oklch(0.6 0.118 184.704); 28 | --chart-3: oklch(0.398 0.07 227.392); 29 | --chart-4: oklch(0.828 0.189 84.429); 30 | --chart-5: oklch(0.769 0.188 70.08); 31 | --sidebar: oklch(0.985 0 0); 32 | --sidebar-foreground: oklch(0.145 0 0); 33 | --sidebar-primary: oklch(0.205 0 0); 34 | --sidebar-primary-foreground: oklch(0.985 0 0); 35 | --sidebar-accent: oklch(0.97 0 0); 36 | --sidebar-accent-foreground: oklch(0.205 0 0); 37 | --sidebar-border: oklch(0.922 0 0); 38 | --sidebar-ring: oklch(0.708 0 0); 39 | } 40 | 41 | .dark { 42 | --background: oklch(0.145 0 0); 43 | --foreground: oklch(0.985 0 0); 44 | --card: oklch(0.205 0 0); 45 | --card-foreground: oklch(0.985 0 0); 46 | --popover: oklch(0.205 0 0); 47 | --popover-foreground: oklch(0.985 0 0); 48 | --primary: oklch(0.922 0 0); 49 | --primary-foreground: oklch(0.205 0 0); 50 | --secondary: oklch(0.269 0 0); 51 | --secondary-foreground: oklch(0.985 0 0); 52 | --muted: oklch(0.269 0 0); 53 | --muted-foreground: oklch(0.708 0 0); 54 | --accent: oklch(0.269 0 0); 55 | --accent-foreground: oklch(0.985 0 0); 56 | --destructive: oklch(0.704 0.191 22.216); 57 | --border: oklch(1 0 0 / 10%); 58 | --input: oklch(1 0 0 / 15%); 59 | --ring: oklch(0.556 0 0); 60 | --chart-1: oklch(0.488 0.243 264.376); 61 | --chart-2: oklch(0.696 0.17 162.48); 62 | --chart-3: oklch(0.769 0.188 70.08); 63 | --chart-4: oklch(0.627 0.265 303.9); 64 | --chart-5: oklch(0.645 0.246 16.439); 65 | --sidebar: oklch(0.205 0 0); 66 | --sidebar-foreground: oklch(0.985 0 0); 67 | --sidebar-primary: oklch(0.488 0.243 264.376); 68 | --sidebar-primary-foreground: oklch(0.985 0 0); 69 | --sidebar-accent: oklch(0.269 0 0); 70 | --sidebar-accent-foreground: oklch(0.985 0 0); 71 | --sidebar-border: oklch(1 0 0 / 10%); 72 | --sidebar-ring: oklch(0.556 0 0); 73 | } 74 | 75 | @theme inline { 76 | --color-background: var(--background); 77 | --color-foreground: var(--foreground); 78 | --color-card: var(--card); 79 | --color-card-foreground: var(--card-foreground); 80 | --color-popover: var(--popover); 81 | --color-popover-foreground: var(--popover-foreground); 82 | --color-primary: var(--primary); 83 | --color-primary-foreground: var(--primary-foreground); 84 | --color-secondary: var(--secondary); 85 | --color-secondary-foreground: var(--secondary-foreground); 86 | --color-muted: var(--muted); 87 | --color-muted-foreground: var(--muted-foreground); 88 | --color-accent: var(--accent); 89 | --color-accent-foreground: var(--accent-foreground); 90 | --color-destructive: var(--destructive); 91 | --color-destructive-foreground: var(--destructive-foreground); 92 | --color-border: var(--border); 93 | --color-input: var(--input); 94 | --color-ring: var(--ring); 95 | --color-chart-1: var(--chart-1); 96 | --color-chart-2: var(--chart-2); 97 | --color-chart-3: var(--chart-3); 98 | --color-chart-4: var(--chart-4); 99 | --color-chart-5: var(--chart-5); 100 | --radius-sm: calc(var(--radius) - 4px); 101 | --radius-md: calc(var(--radius) - 2px); 102 | --radius-lg: var(--radius); 103 | --radius-xl: calc(var(--radius) + 4px); 104 | --color-sidebar: var(--sidebar); 105 | --color-sidebar-foreground: var(--sidebar-foreground); 106 | --color-sidebar-primary: var(--sidebar-primary); 107 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 108 | --color-sidebar-accent: var(--sidebar-accent); 109 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 110 | --color-sidebar-border: var(--sidebar-border); 111 | --color-sidebar-ring: var(--sidebar-ring); 112 | } 113 | 114 | @layer base { 115 | * { 116 | @apply border-border outline-ring/50; 117 | } 118 | body { 119 | @apply bg-background text-foreground; 120 | } 121 | } -------------------------------------------------------------------------------- /web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /web/src/pages/Callback.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate, useSearchParams } from "react-router-dom"; 3 | import { Loader2, AlertCircle } from "lucide-react"; 4 | import { Button } from "../components/ui/button"; 5 | import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../components/ui/card"; 6 | 7 | interface CallbackProps { 8 | setIsAuthenticated: (value: boolean) => void; 9 | } 10 | 11 | const Callback = ({ setIsAuthenticated }: CallbackProps) => { 12 | const [searchParams] = useSearchParams(); 13 | const navigate = useNavigate(); 14 | const [error, setError] = useState(null); 15 | 16 | useEffect(() => { 17 | const code = searchParams.get("code"); 18 | 19 | if (!code) { 20 | setError("No code provided by GitHub"); 21 | return; 22 | } 23 | 24 | const authenticateWithGitHub = async () => { 25 | try { 26 | const response = await fetch(`/api/code?code=${code}`, { 27 | method: 'GET', 28 | credentials: 'include', // Important to include cookies 29 | }); 30 | 31 | if (!response.ok) { 32 | throw new Error(`Authentication failed: ${response.statusText}`); 33 | } 34 | 35 | // Set authentication state to true 36 | setIsAuthenticated(true); 37 | 38 | // Redirect to dashboard or home page 39 | navigate("/dashboard"); 40 | } catch (err) { 41 | setError(err instanceof Error ? err.message : "Authentication failed"); 42 | } 43 | }; 44 | 45 | authenticateWithGitHub(); 46 | }, [searchParams, navigate, setIsAuthenticated]); 47 | 48 | if (error) { 49 | return ( 50 |
51 | 52 | 53 |
54 | 55 |
56 | 授权失败 57 |
58 | 59 |

{error}

60 |
61 | 62 | 65 | 66 |
67 |
68 | ); 69 | } 70 | 71 | return ( 72 |
73 | 74 | 75 |
76 | 77 |
78 |
79 |

正在授权访问 GitHub 数据

80 |

请稍等,我们正在登录...

81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default Callback; 88 | -------------------------------------------------------------------------------- /web/src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../components/ui/button"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { Card, CardContent } from "../components/ui/card"; 4 | import { 5 | Users, 6 | Code, 7 | BarChartHorizontal, 8 | Loader2, 9 | MapPin, 10 | Briefcase, 11 | Globe, 12 | LogOut, 13 | Calendar, 14 | PersonStanding, 15 | Notebook, 16 | } from "lucide-react"; 17 | import { useState, useEffect } from "react"; 18 | import { 19 | Select, 20 | SelectContent, 21 | SelectGroup, 22 | SelectItem, 23 | SelectTrigger, 24 | SelectValue, 25 | } from "../components/ui/select"; 26 | 27 | // GitHub user interface based on the API response 28 | interface GitHubUser { 29 | login: string; 30 | id: number; 31 | node_id: string; 32 | avatar_url: string; 33 | gravatar_id: string; 34 | url: string; 35 | html_url: string; 36 | followers_url: string; 37 | following_url: string; 38 | gists_url: string; 39 | starred_url: string; 40 | subscriptions_url: string; 41 | organizations_url: string; 42 | repos_url: string; 43 | events_url: string; 44 | received_events_url: string; 45 | type: string; 46 | user_view_type: string; 47 | site_admin: boolean; 48 | name: string | null; 49 | company: string | null; 50 | blog: string | null; 51 | location: string | null; 52 | email: string | null; 53 | hireable: boolean | null; 54 | bio: string | null; 55 | twitter_username: string | null; 56 | notification_email: string | null; 57 | public_repos: number; 58 | public_gists: number; 59 | followers: number; 60 | following: number; 61 | created_at: string; 62 | updated_at: string; 63 | } 64 | 65 | // Preset interface for report generation 66 | interface Preset { 67 | name: string; 68 | description: string; 69 | } 70 | 71 | const Dashboard = () => { 72 | const navigate = useNavigate(); 73 | const [userData, setUserData] = useState(null); 74 | const [isLoading, setIsLoading] = useState(true); 75 | const [presets, setPresets] = useState([]); 76 | const [selectedPreset, setSelectedPreset] = useState(""); 77 | 78 | useEffect(() => { 79 | const fetchData = async () => { 80 | try { 81 | setIsLoading(true); 82 | 83 | // Fetch user data 84 | const userResponse = await fetch("/api/user"); 85 | if (!userResponse.ok) { 86 | throw new Error(`Failed to fetch user data: ${userResponse.status}`); 87 | } 88 | const userData = await userResponse.json(); 89 | setUserData(userData); 90 | 91 | // Fetch presets 92 | const presetsResponse = await fetch("/api/presets"); 93 | if (presetsResponse.ok) { 94 | const presetsData = await presetsResponse.json(); 95 | setPresets(presetsData.presets || []); 96 | if (presetsData.presets && presetsData.presets.length > 0) { 97 | setSelectedPreset(presetsData.presets[0].name); 98 | } 99 | } 100 | } catch (err) { 101 | document.cookie = 102 | "session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; 103 | setTimeout(() => { 104 | window.location.href = "/login"; 105 | }, 100); 106 | console.error("Error fetching data:", err); 107 | } finally { 108 | setIsLoading(false); 109 | } 110 | }; 111 | 112 | fetchData(); 113 | }, []); 114 | 115 | const handleLogout = () => { 116 | document.cookie = 117 | "session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; 118 | setTimeout(() => { 119 | window.location.href = "/login"; 120 | }, 100); 121 | }; 122 | 123 | const handleGenerateReport = () => { 124 | navigate(`/report${selectedPreset ? `?preset=${selectedPreset}` : ""}`); 125 | }; 126 | 127 | if (isLoading) { 128 | return ( 129 |
130 |
131 |
132 | 133 |
134 |
135 |

正在加载 GitHub 信息...

136 |

137 | 请稍等,我们正在获取您的数据 138 |

139 |
140 |
141 | ); 142 | } 143 | 144 | return ( 145 |
146 | {/* Top navbar */} 147 |
148 |
149 | 157 |
158 |
159 | 160 |
161 | {/* Header with profile information */} 162 |
163 |
164 | {userData?.avatar_url && ( 165 |
166 |
167 | {`${userData.login}的头像`} 172 |
173 | )} 174 |
175 |
176 |

177 | {userData?.name || userData?.login || "GitHub 用户"} 178 |

179 |

180 | @{userData?.login} 181 |

182 | 183 | {userData?.created_at && ( 184 |
185 | 186 | 187 | 加入于{" "} 188 | {new Date(userData.created_at).toLocaleDateString( 189 | "zh-CN", 190 | { 191 | year: "numeric", 192 | month: "short", 193 | } 194 | )} 195 | 196 |
197 | )} 198 |
199 | 200 | {userData?.bio && ( 201 |
202 |

203 | {userData.bio} 204 |

205 |
206 | )} 207 | 208 |
209 | {userData?.company && ( 210 |
211 | 212 | {userData.company} 213 |
214 | )} 215 | {userData?.location && ( 216 |
217 | 218 | {userData.location} 219 |
220 | )} 221 |
222 |
223 |
224 |
225 | 226 | {/* Stats Cards */} 227 |
228 | {[ 229 | { 230 | title: "仓库", 231 | value: userData?.public_repos.toString() || "0", 232 | icon: , 233 | color: 234 | "border-blue-200 bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20 dark:to-transparent", 235 | }, 236 | { 237 | title: "关注者", 238 | value: userData?.followers.toString() || "0", 239 | icon: , 240 | color: 241 | "border-green-200 bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20 dark:to-transparent", 242 | }, 243 | { 244 | title: "关注", 245 | value: userData?.following.toString() || "0", 246 | icon: , 247 | color: 248 | "border-yellow-200 bg-gradient-to-br from-yellow-50 to-transparent dark:from-yellow-950/20 dark:to-transparent", 249 | }, 250 | { 251 | title: "Gist 数量", 252 | value: userData?.public_gists.toString() || "0", 253 | icon: , 254 | color: 255 | "border-red-200 bg-gradient-to-br from-red-50 to-transparent dark:from-red-950/20 dark:to-transparent", 256 | }, 257 | ].map((stat, index) => ( 258 | 262 | 263 |
264 |
265 |

266 | {stat.title} 267 |

268 |
269 |

{stat.value}

270 |
271 |
272 |
273 | {stat.icon} 274 |
275 |
276 |
277 |
278 | ))} 279 |
280 | 281 | {/* Links row */} 282 |
283 | {userData?.blog && ( 284 | 294 | 295 | 296 | {userData.blog.replace(/(https?:\/\/)?(www\.)?/i, "")} 297 | 298 | 299 | )} 300 | {userData?.html_url && ( 301 | 307 | 308 | GitHub 个人页面 309 | 310 | )} 311 |
312 | 313 | {/* Generate Report Section */} 314 |
315 | {/* Decorative elements - simplified */} 316 |
317 |
318 | 319 | {/* Content */} 320 |
321 |
322 |
323 | 324 |
325 |

326 | GitHub AI 锐评 327 |

328 |

329 | 锐评一下你都在 GitHub 写了什么。 330 |

331 |
332 | 333 |

334 | Powered by 335 | 340 | Logo 345 | BingyanStudio 346 | 347 |

348 | 349 |
350 | {presets.length > 0 && ( 351 |
352 | 369 |
370 | )} 371 | 372 | 379 |
380 |
381 |
382 |
383 |
384 | ); 385 | }; 386 | 387 | export default Dashboard; 388 | -------------------------------------------------------------------------------- /web/src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../components/ui/button"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardFooter, 7 | CardHeader, 8 | CardTitle, 9 | } from "../components/ui/card"; 10 | import { Github } from "lucide-react"; 11 | 12 | const Login = () => { 13 | const handleGithubLogin = () => { 14 | const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID; 15 | const redirectUri = `${window.location.origin}/callback`; 16 | const scope = "user repo"; 17 | 18 | window.location.href = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}`; 19 | }; 20 | 21 | return ( 22 |
23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 | GitHub 锐评 37 | 让 AI 锐评一下你都在做什么吧 38 |
39 |
40 | 41 | 48 | 49 | 50 |

51 | Powered by 52 | 57 | Logo 62 | BingyanStudio 63 | 64 |

65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | export default Login; 72 | -------------------------------------------------------------------------------- /web/src/pages/Report.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useNavigate, useLocation } from "react-router-dom"; 3 | import { Card } from "../components/ui/card"; 4 | import ReportLoading from "../components/report/ReportLoading"; 5 | import ReportError from "../components/report/ReportError"; 6 | import ReportHeader from "../components/report/ReportHeader"; 7 | import ReportContent from "../components/report/ReportContent"; 8 | import ReportActions from "../components/report/ReportActions"; 9 | import { generateReportImage } from "../components/report/imageGenerator"; 10 | 11 | interface Preset { 12 | name: string; 13 | description: string; 14 | } 15 | 16 | const Report = () => { 17 | const [report, setReport] = useState(null); 18 | const [isLoading, setIsLoading] = useState(true); 19 | const [error, setError] = useState(null); 20 | const [presets, setPresets] = useState([]); 21 | const [selectedPreset, setSelectedPreset] = useState(""); 22 | const [isPresetLoaded, setIsPresetLoaded] = useState(false); 23 | const [isStreaming, setIsStreaming] = useState(false); 24 | const [statusMessage, setStatusMessage] = useState(null); 25 | const [errorMessage, setErrorMessage] = useState(null); 26 | const navigate = useNavigate(); 27 | const location = useLocation(); 28 | 29 | // Parse preset from URL query parameter 30 | useEffect(() => { 31 | const queryParams = new URLSearchParams(location.search); 32 | const presetParam = queryParams.get("preset"); 33 | if (presetParam) { 34 | setSelectedPreset(presetParam); 35 | } 36 | setIsPresetLoaded(true); 37 | }, [location.search]); 38 | 39 | // Fetch presets 40 | useEffect(() => { 41 | const fetchPresets = async () => { 42 | try { 43 | const response = await fetch("/api/presets"); 44 | if (response.ok) { 45 | const data = await response.json(); 46 | setPresets(data.presets || []); 47 | } 48 | } catch (err) { 49 | console.error("Error fetching presets:", err); 50 | } 51 | }; 52 | 53 | fetchPresets(); 54 | }, []); 55 | 56 | // Fetch report with the selected preset 57 | useEffect(() => { 58 | // Only fetch the report once preset information is loaded from URL 59 | if (!isPresetLoaded) return; 60 | 61 | const fetchReport = async () => { 62 | try { 63 | setIsLoading(true); 64 | setError(null); 65 | setReport(""); 66 | setStatusMessage("准备生成报告..."); 67 | 68 | const url = selectedPreset 69 | ? `/api/report?preset=${encodeURIComponent(selectedPreset)}` 70 | : "/api/report"; 71 | 72 | setIsStreaming(true); 73 | 74 | const eventSource = new EventSource( 75 | url + (url.includes("?") ? "&" : "?") + "stream=true", 76 | { 77 | withCredentials: true, 78 | } 79 | ); 80 | 81 | // Handle different event types 82 | eventSource.addEventListener("chunk", (event) => { 83 | try { 84 | const data = JSON.parse(event.data); 85 | setReport((prev) => (prev || "") + data.content); 86 | } catch (err) { 87 | console.log(err); 88 | // If it's not valid JSON, treat it as plain text 89 | setReport((prev) => (prev || "") + event.data); 90 | } 91 | }); 92 | 93 | eventSource.addEventListener("status", (event) => { 94 | try { 95 | const data = JSON.parse(event.data); 96 | setStatusMessage(data.message); 97 | } catch (err) { 98 | console.error("Error parsing status message:", err); 99 | } 100 | }); 101 | 102 | eventSource.addEventListener("complete", (event) => { 103 | try { 104 | const data = JSON.parse(event.data); 105 | if (data.message) { 106 | setReport(data.message); 107 | } 108 | setStatusMessage("报告生成完成"); 109 | setTimeout(() => setStatusMessage(null), 3000); 110 | } catch (err) { 111 | console.error("Error parsing complete message:", err); 112 | } finally { 113 | eventSource.close(); 114 | setIsStreaming(false); 115 | setIsLoading(false); 116 | } 117 | }); 118 | 119 | eventSource.addEventListener("generate_error", (event) => { 120 | try { 121 | const data = JSON.parse(event.data); 122 | setErrorMessage(data.message || "服务器繁忙,请稍后再试。"); 123 | } catch (err) { 124 | console.error("Error while generation:", err); 125 | } finally { 126 | eventSource.close(); 127 | setIsStreaming(false); 128 | setIsLoading(false); 129 | setStatusMessage(null); 130 | } 131 | }); 132 | 133 | eventSource.onerror = () => { 134 | // Close the connection on error 135 | eventSource.close(); 136 | setIsStreaming(false); 137 | setIsLoading(false); 138 | setStatusMessage(null); 139 | }; 140 | 141 | // Cleanup function to close SSE connection when component unmounts 142 | return () => { 143 | eventSource.close(); 144 | }; 145 | } catch (err) { 146 | console.error("Error fetching report:", err); 147 | setError( 148 | err instanceof Error ? err.message : "Failed to generate report" 149 | ); 150 | setIsLoading(false); 151 | } 152 | }; 153 | 154 | fetchReport(); 155 | }, [selectedPreset, isPresetLoaded]); 156 | 157 | const handleBackToDashboard = () => { 158 | navigate("/dashboard"); 159 | }; 160 | 161 | const handlePresetChange = (value: string) => { 162 | setSelectedPreset(value); 163 | // Update URL to reflect preset change 164 | navigate(`/report?preset=${encodeURIComponent(value)}`, { replace: true }); 165 | }; 166 | 167 | // Function to handle report regeneration 168 | const handleRegenerateReport = async () => { 169 | try { 170 | setIsLoading(true); 171 | setError(null); 172 | setReport(""); 173 | setStatusMessage("准备重新生成报告..."); 174 | 175 | const url = selectedPreset 176 | ? `/api/report?preset=${encodeURIComponent( 177 | selectedPreset 178 | )}&force_regen=true` 179 | : "/api/report?force_regen=true"; 180 | 181 | // Check if the browser supports EventSource 182 | if (typeof EventSource !== "undefined") { 183 | setIsStreaming(true); 184 | 185 | const eventSource = new EventSource(url + "&stream=true", { 186 | withCredentials: true, 187 | }); 188 | 189 | // Handle different event types 190 | eventSource.addEventListener("chunk", (event) => { 191 | try { 192 | const data = JSON.parse(event.data); 193 | setReport((prev) => (prev || "") + data.content); 194 | } catch (err) { 195 | console.log(err); 196 | // If it's not valid JSON, treat it as plain text 197 | setReport((prev) => (prev || "") + event.data); 198 | } 199 | }); 200 | 201 | eventSource.addEventListener("status", (event) => { 202 | try { 203 | const data = JSON.parse(event.data); 204 | setStatusMessage(data.message); 205 | } catch (err) { 206 | console.error("Error parsing status message:", err); 207 | } 208 | }); 209 | 210 | eventSource.addEventListener("complete", (event) => { 211 | try { 212 | const data = JSON.parse(event.data); 213 | if (data.message) { 214 | setReport(data.message); 215 | } 216 | setStatusMessage("报告重新生成完成"); 217 | setTimeout(() => setStatusMessage(null), 3000); 218 | } catch (err) { 219 | console.error("Error parsing complete message:", err); 220 | } finally { 221 | eventSource.close(); 222 | setIsStreaming(false); 223 | setIsLoading(false); 224 | } 225 | }); 226 | 227 | eventSource.addEventListener("generate_error", (event) => { 228 | try { 229 | const data = JSON.parse(event.data); 230 | setErrorMessage(data.message || "服务器繁忙,请稍后再试。"); 231 | } catch (err) { 232 | console.error("Error while generation:", err); 233 | } finally { 234 | eventSource.close(); 235 | setIsStreaming(false); 236 | setIsLoading(false); 237 | setStatusMessage(null); 238 | } 239 | }); 240 | 241 | eventSource.onerror = () => { 242 | // Close the connection on error 243 | eventSource.close(); 244 | setErrorMessage("服务器繁忙,请稍后再试。"); 245 | setIsStreaming(false); 246 | setIsLoading(false); 247 | setStatusMessage(null); 248 | }; 249 | } else { 250 | // Fallback to traditional fetch 251 | const response = await fetch(url, { 252 | credentials: "include", 253 | }); 254 | 255 | if (!response.ok) { 256 | throw new Error(`Report regeneration failed: ${response.statusText}`); 257 | } 258 | 259 | const data = await response.json(); 260 | setReport(data.message); 261 | setIsLoading(false); 262 | } 263 | } catch (err) { 264 | console.error("Error regenerating report:", err); 265 | setError( 266 | err instanceof Error ? err.message : "Failed to regenerate report" 267 | ); 268 | setIsLoading(false); 269 | } 270 | }; 271 | 272 | // Function to handle image generation 273 | const handleGenerateImage = async () => { 274 | try { 275 | setIsLoading(true); 276 | 277 | if (!report) { 278 | throw new Error("No report data available"); 279 | } 280 | 281 | await generateReportImage(report, selectedPreset, presets); 282 | } catch (err) { 283 | console.error("Error generating image:", err); 284 | setError(err instanceof Error ? err.message : "Failed to generate image"); 285 | } finally { 286 | setIsLoading(false); 287 | } 288 | }; 289 | 290 | if (isLoading && !report) { 291 | return ; 292 | } 293 | 294 | if (error) { 295 | return ( 296 | 301 | ); 302 | } 303 | 304 | return ( 305 |
306 | {/* Add pt-16 for padding top to avoid overlap with fixed theme toggle */} 307 |
308 | 314 | 315 |
316 | 317 | 323 | 324 | 331 | 332 |
333 |
334 |
335 | ); 336 | }; 337 | 338 | export default Report; 339 | -------------------------------------------------------------------------------- /web/src/utils/reportUtils.ts: -------------------------------------------------------------------------------- 1 | export interface FormattedSection { 2 | title: string; 3 | content: string[]; 4 | } 5 | 6 | export const formatReportSections = (text: string): FormattedSection[] => { 7 | if (!text) return []; 8 | 9 | // Split the report by lines first 10 | const lines = text.split("\n"); 11 | const sections: FormattedSection[] = []; 12 | let currentSection: FormattedSection | null = null; 13 | 14 | // Process each line to organize into sections 15 | lines.forEach((line) => { 16 | // Check if this is a section title (starts with ">>") 17 | if (line.trim().startsWith(">>")) { 18 | // If we have a current section, add it to our sections array 19 | if (currentSection) { 20 | sections.push(currentSection); 21 | } 22 | // Start a new section with this title 23 | currentSection = { 24 | title: line.trim().substring(2).trim(), // Remove ">>" prefix and trim 25 | content: [], 26 | }; 27 | } 28 | // Otherwise this is content for the current section 29 | else if (currentSection) { 30 | currentSection.content.push(line); 31 | } 32 | // If we encounter content before any title, create a default section 33 | else { 34 | currentSection = { 35 | title: "", 36 | content: [line], 37 | }; 38 | } 39 | }); 40 | 41 | // Add the last section if it exists 42 | if (currentSection) { 43 | sections.push(currentSection); 44 | } 45 | 46 | return sections; 47 | }; 48 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: "hsl(var(--border))", 11 | input: "hsl(var(--input))", 12 | ring: "hsl(var(--ring))", 13 | background: "hsl(var(--background))", 14 | foreground: "hsl(var(--foreground))", 15 | primary: { 16 | DEFAULT: "hsl(var(--primary))", 17 | foreground: "hsl(var(--primary-foreground))", 18 | }, 19 | secondary: { 20 | DEFAULT: "hsl(var(--secondary))", 21 | foreground: "hsl(var(--secondary-foreground))", 22 | }, 23 | destructive: { 24 | DEFAULT: "hsl(var(--destructive))", 25 | foreground: "hsl(var(--destructive-foreground))", 26 | }, 27 | muted: { 28 | DEFAULT: "hsl(var(--muted))", 29 | foreground: "hsl(var(--muted-foreground))", 30 | }, 31 | accent: { 32 | DEFAULT: "hsl(var(--accent))", 33 | foreground: "hsl(var(--accent-foreground))", 34 | }, 35 | card: { 36 | DEFAULT: "hsl(var(--card))", 37 | foreground: "hsl(var(--card-foreground))", 38 | }, 39 | }, 40 | }, 41 | }, 42 | plugins: [], 43 | } 44 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import path from "path"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | "@": path.resolve(__dirname, "./src"), 12 | }, 13 | }, 14 | server: { 15 | proxy: { 16 | "/api": { 17 | target: "http://localhost:3000", 18 | changeOrigin: true, 19 | proxyTimeout: 60000, 20 | timeout: 60000, 21 | }, 22 | }, 23 | }, 24 | }); 25 | --------------------------------------------------------------------------------