From afb270721cf8b62075cc8aae5f9b28565d8bf230 Mon Sep 17 00:00:00 2001 From: wonder Date: Fri, 19 Jun 2026 19:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20LLM=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=EF=BC=8CPR=20=E6=8F=8F=E8=BF=B0=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=8E=20AI=20=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 14 +- go.mod | 3 +- go.sum | 47 ++++--- handlers/generate.go | 63 ++++++++- handlers/review.go | 84 +++++++++++- services/generate.go | 151 ++++++++++++++++++++++ services/llm.go | 152 ++++++++++++++++++++++ services/review.go | 298 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 783 insertions(+), 29 deletions(-) create mode 100644 services/generate.go create mode 100644 services/llm.go create mode 100644 services/review.go diff --git a/PLAN.md b/PLAN.md index c63c55a..1ea09a8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -713,12 +713,12 @@ volumes: ## 10. 开发阶段 -> **当前进度**:Phase 1-3 已完成,Phase 4-6 待开发 +> **当前进度**:Phase 1-4 已完成,Phase 5-6 待开发 > > - ✅ Phase 1(基础骨架):Go 项目、Gin 路由、SQLite、设置页面 > - ✅ Phase 2(Git 核心):clone、refs、diff、graph 数据、缓存管理 > - ✅ Phase 3(前端交互):Git Graph、Diff 查看器、文件树、SSE 流式展示、行内建议 -> - ⏳ Phase 4(LLM 集成):待开发 +> - ✅ Phase 4(LLM 集成):OpenAI 兼容 API、PR 描述生成、AI 代码审查、Top-N 策略 > - ⏳ Phase 5(审查编辑与导出):待开发 > - ⚠️ Phase 6(完善与部署):Docker 配置已完成,其他待完善 @@ -745,11 +745,11 @@ volumes: - [x] AI Review 行内建议展示(嵌入代码行旁) - [x] SSE 流式展示组件(sse.js) -### Phase 4:LLM 集成 ⏳ 待开发 -- [ ] OpenAI 兼容 API 调用封装(services/llm.go) -- [ ] PR 描述生成(prompt + SSE 流式)(services/generate.go) -- [ ] AI 代码审查(分文件处理 + 汇总)(services/review.go) -- [ ] 大 diff 处理(Top-N 策略) +### Phase 4:LLM 集成 ✅ 已完成 +- [x] OpenAI 兼容 API 调用封装(services/llm.go) +- [x] PR 描述生成(prompt + SSE 流式)(services/generate.go) +- [x] AI 代码审查(分文件处理 + 汇总)(services/review.go) +- [x] 大 diff 处理(Top-N 策略) ### Phase 5:审查编辑与导出 ⏳ 待开发 - [ ] 备注编辑器组件(Markdown 支持,三级作用域) diff --git a/go.mod b/go.mod index db1cfc1..0cbf63d 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 + github.com/go-git/go-git/v5 v5.19.1 github.com/mattn/go-sqlite3 v1.14.24 + github.com/sashabaranov/go-openai v1.41.2 ) require ( @@ -22,7 +24,6 @@ require ( github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-git/go-git/v5 v5.19.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect diff --git a/go.sum b/go.sum index 82b3e74..c6cd173 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -20,6 +24,8 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -28,10 +34,14 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -46,8 +56,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= @@ -56,14 +66,16 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -75,13 +87,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= +github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -99,8 +118,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -111,13 +131,11 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -126,27 +144,22 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/handlers/generate.go b/handlers/generate.go index 6c64477..da2b29c 100644 --- a/handlers/generate.go +++ b/handlers/generate.go @@ -2,8 +2,11 @@ package handlers import ( "database/sql" + "encoding/json" + "fmt" "net/http" + "github.com/HoHD/PR-Helper/services" "github.com/gin-gonic/gin" ) @@ -15,7 +18,63 @@ func NewGenerateHandler(db *sql.DB) *GenerateHandler { return &GenerateHandler{db: db} } -// Generate handles POST /api/repos/:id/generate — stub for Phase 4 +// Generate handles POST /api/repos/:id/generate — SSE streaming PR description generation. func (h *GenerateHandler) Generate(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "generate not implemented yet — coming in Phase 4"}) + id := c.Param("id") + + // Get repo info + var localPath string + err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Parse request + var req struct { + Base string `json:"base" binding:"required"` + Head string `json:"head" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"}) + return + } + + // Set SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"}) + return + } + + sendEvent := func(event string, data interface{}) { + jsonData, _ := json.Marshal(data) + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData) + flusher.Flush() + } + + // Update last_used + h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id) + + // Generate PR description + pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, sendEvent) + if err != nil { + sendEvent("error", map[string]interface{}{"message": err.Error()}) + return + } + + // Save analysis to DB + resultJSON, _ := json.Marshal(pr) + h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'pr_description', ?, ?, ?)`, + id, req.Base, req.Head, string(resultJSON)) } diff --git a/handlers/review.go b/handlers/review.go index 5d85cf9..42b8052 100644 --- a/handlers/review.go +++ b/handlers/review.go @@ -2,8 +2,12 @@ package handlers import ( "database/sql" + "encoding/json" + "fmt" "net/http" + "strconv" + "github.com/HoHD/PR-Helper/services" "github.com/gin-gonic/gin" ) @@ -15,9 +19,85 @@ func NewReviewHandler(db *sql.DB) *ReviewHandler { return &ReviewHandler{db: db} } -// Review handles POST /api/repos/:id/review — stub for Phase 4 +// Review handles POST /api/repos/:id/review — SSE streaming AI code review. func (h *ReviewHandler) Review(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "review not implemented yet — coming in Phase 4"}) + id := c.Param("id") + + // Get repo info + var localPath string + err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Parse request + var req struct { + Base string `json:"base" binding:"required"` + Head string `json:"head" binding:"required"` + TopN *int `json:"top_n"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"}) + return + } + + // Determine Top-N: request value > settings default (20) + topN := 20 + if req.TopN != nil { + topN = *req.TopN + } else { + var topNStr string + h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topNStr) + if topNStr != "" { + if n, err := strconv.Atoi(topNStr); err == nil && n > 0 { + topN = n + } + } + } + + // Set SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"}) + return + } + + sendEvent := func(event string, data interface{}) { + jsonData, _ := json.Marshal(data) + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData) + flusher.Flush() + } + + // Update last_used + h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id) + + // Run AI review + err = services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, sendEvent) + if err != nil { + sendEvent("error", map[string]interface{}{"message": err.Error()}) + return + } + + // Save analysis to DB + resultData := map[string]interface{}{ + "base": req.Base, + "head": req.Head, + "top_n": topN, + } + resultJSON, _ := json.Marshal(resultData) + h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`, + id, req.Base, req.Head, string(resultJSON)) } // SaveNotes handles POST /api/repos/:id/review/notes — stub for Phase 5 diff --git a/services/generate.go b/services/generate.go new file mode 100644 index 0000000..d103f39 --- /dev/null +++ b/services/generate.go @@ -0,0 +1,151 @@ +package services + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + goopenai "github.com/sashabaranov/go-openai" +) + +// PRDescription is the structured output from PR description generation. +type PRDescription struct { + Title string `json:"title"` + Type string `json:"type"` + Summary string `json:"summary"` + Details string `json:"details"` + Impact string `json:"impact"` +} + +// GeneratePR generates a structured PR description from commit history and diff. +// It streams progress via the callback and returns the parsed PR description. +func GeneratePR(db *sql.DB, repoPath, base, head string, callback StreamCallback) (*PRDescription, error) { + // Read LLM config + config, err := GetLLMConfig(db) + if err != nil { + return nil, err + } + + // Open repo + repo, err := OpenRepo(repoPath) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + // Get commits between base and head + commits, err := GetCommitLog(repo, head, 100) + if err != nil { + return nil, fmt.Errorf("get commits: %w", err) + } + + // Filter commits to only those reachable from head but not from base + baseHash, err := repo.ResolveRevision(plumbing.Revision(base)) + if err == nil { + baseCommit, bcErr := repo.CommitObject(*baseHash) + if bcErr == nil { + baseSet := make(map[string]bool) + baseQueue := []*object.Commit{baseCommit} + for len(baseQueue) > 0 { + c := baseQueue[0] + baseQueue = baseQueue[1:] + if baseSet[c.Hash.String()] { + continue + } + baseSet[c.Hash.String()] = true + for _, p := range c.ParentHashes { + pc, err := repo.CommitObject(p) + if err == nil { + baseQueue = append(baseQueue, pc) + } + } + } + var filtered []CommitInfo + for _, ci := range commits { + if !baseSet[ci.Hash] { + filtered = append(filtered, ci) + } + } + if len(filtered) > 0 { + commits = filtered + } + } + } + + // Get diff + diff, err := GetDiff(repo, base, head) + if err != nil { + return nil, fmt.Errorf("get diff: %w", err) + } + + // Build prompt + var commitLines []string + for _, c := range commits { + commitLines = append(commitLines, fmt.Sprintf("- %s %s", c.ShortHash, c.Message)) + } + + commitStr := strings.Join(commitLines, "\n") + if commitStr == "" { + commitStr = "(no commits)" + } + + // Truncate diff if too large (approx 60k chars to stay within token limits) + if len(diff) > 60000 { + diff = diff[:60000] + "\n\n... [diff truncated due to size]" + } + + prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份结构化的 PR 描述。 + +## Commit 记录 +%s + +## 代码变更 (Diff) +%s + +请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记): +{ + "title": "简洁的 PR 标题", + "type": "变更类型: feat|fix|refactor|docs|chore|style|test|perf", + "summary": "一段话概述变更内容", + "details": "详细的变更说明,按模块分组,使用 Markdown 格式", + "impact": "影响范围说明" +}`, commitStr, diff) + + messages := []goopenai.ChatCompletionMessage{ + {Role: goopenai.ChatMessageRoleUser, Content: prompt}, + } + + // Call LLM with streaming + fullResponse, err := ChatStream(config, messages, callback) + if err != nil { + return nil, fmt.Errorf("LLM call: %w", err) + } + + // Parse JSON response + jsonStr := extractJSON(fullResponse) + var pr PRDescription + if err := json.Unmarshal([]byte(jsonStr), &pr); err != nil { + return nil, fmt.Errorf("parse PR description: %w (raw: %s)", err, truncateString(fullResponse, 200)) + } + + // Send structured events + if callback != nil { + callback("title", map[string]interface{}{"content": pr.Title}) + callback("type", map[string]interface{}{"content": pr.Type}) + callback("summary", map[string]interface{}{"content": pr.Summary}) + callback("detail", map[string]interface{}{"content": pr.Details}) + callback("impact", map[string]interface{}{"content": pr.Impact}) + callback("done", map[string]interface{}{"content": ""}) + } + + return &pr, nil +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/services/llm.go b/services/llm.go new file mode 100644 index 0000000..91e8cce --- /dev/null +++ b/services/llm.go @@ -0,0 +1,152 @@ +package services + +import ( + "context" + "database/sql" + "fmt" + "io" + "strings" + + goopenai "github.com/sashabaranov/go-openai" +) + +// LLMConfig holds LLM API configuration. +type LLMConfig struct { + Endpoint string + APIKey string + Model string +} + +// GetLLMConfig reads LLM settings from the database. +func GetLLMConfig(db *sql.DB) (LLMConfig, error) { + config := LLMConfig{} + + rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN ('llm.endpoint', 'llm.api_key', 'llm.model')`) + if err != nil { + return config, fmt.Errorf("read settings: %w", err) + } + defer rows.Close() + + for rows.Next() { + var key, value string + if rows.Scan(&key, &value) == nil { + switch key { + case "llm.endpoint": + config.Endpoint = value + case "llm.api_key": + config.APIKey = value + case "llm.model": + config.Model = value + } + } + } + + if config.APIKey == "" { + return config, fmt.Errorf("LLM API key not configured — please set it in the settings page") + } + + if config.Model == "" { + config.Model = "gpt-4o" + } + + return config, nil +} + +// StreamCallback is called for each SSE event during LLM streaming. +type StreamCallback func(event string, data interface{}) + +// ChatStream sends a streaming chat completion request to an OpenAI-compatible API. +// It calls callback with "content" events for each chunk received. +// Returns the full concatenated response text. +func ChatStream(config LLMConfig, messages []goopenai.ChatCompletionMessage, callback StreamCallback) (string, error) { + clientConfig := goopenai.DefaultConfig(config.APIKey) + if config.Endpoint != "" { + clientConfig.BaseURL = config.Endpoint + } + client := goopenai.NewClientWithConfig(clientConfig) + + ctx := context.Background() + stream, err := client.CreateChatCompletionStream(ctx, goopenai.ChatCompletionRequest{ + Model: config.Model, + Messages: messages, + Stream: true, + }) + if err != nil { + return "", fmt.Errorf("create stream: %w", err) + } + defer stream.Close() + + var fullResponse strings.Builder + for { + response, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return fullResponse.String(), fmt.Errorf("stream recv: %w", err) + } + if len(response.Choices) > 0 { + content := response.Choices[0].Delta.Content + if content != "" { + fullResponse.WriteString(content) + if callback != nil { + callback("content", map[string]interface{}{"content": content}) + } + } + } + } + + return fullResponse.String(), nil +} + +// extractJSON attempts to extract a JSON object or array from a string +// that may contain markdown code blocks or extra text. +func extractJSON(s string) string { + // Try to find JSON in markdown code block + if idx := strings.Index(s, "```json"); idx >= 0 { + start := idx + 7 + if end := strings.Index(s[start:], "```"); end >= 0 { + return strings.TrimSpace(s[start : start+end]) + } + } + if idx := strings.Index(s, "```"); idx >= 0 { + start := idx + 3 + if nl := strings.Index(s[start:], "\n"); nl >= 0 { + start += nl + 1 + } + if end := strings.Index(s[start:], "```"); end >= 0 { + return strings.TrimSpace(s[start : start+end]) + } + } + + // Find first { or [ + startObj := strings.Index(s, "{") + startArr := strings.Index(s, "[") + + var start int + var endChar byte + if startObj >= 0 && (startArr < 0 || startObj < startArr) { + start = startObj + endChar = '}' + } else if startArr >= 0 { + start = startArr + endChar = ']' + } else { + return s + } + + // Find matching closing bracket + depth := 0 + for i := start; i < len(s); i++ { + if s[i] == '{' || s[i] == '[' { + depth++ + } else if s[i] == '}' || s[i] == ']' { + depth-- + if depth == 0 && s[i] == endChar { + return s[start : i+1] + } + } + } + + return s[start:] +} diff --git a/services/review.go b/services/review.go new file mode 100644 index 0000000..8d8f441 --- /dev/null +++ b/services/review.go @@ -0,0 +1,298 @@ +package services + +import ( + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + + goopenai "github.com/sashabaranov/go-openai" +) + +// ReviewSuggestion is a single review finding for a file. +type ReviewSuggestion struct { + Severity string `json:"severity"` + Description string `json:"description"` + Suggestion string `json:"suggestion"` + CodeExample string `json:"code_example,omitempty"` +} + +// FileReview holds the review results for one file. +type FileReview struct { + FileName string `json:"file_name"` + ChangeLines int `json:"change_lines"` + Suggestions []ReviewSuggestion `json:"suggestions"` + RawReview string `json:"raw_review"` +} + +// ReviewSummary is the overall assessment after reviewing all files. +type ReviewSummary struct { + Score int `json:"score"` + Overall string `json:"overall"` + Findings string `json:"findings"` + Recommendations string `json:"recommendations"` +} + +// countDiffLines counts the number of added/removed lines in a diff patch. +func countDiffLines(patch string) int { + count := 0 + for _, line := range strings.Split(patch, "\n") { + if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { + if !strings.HasPrefix(line, "+++") && !strings.HasPrefix(line, "---") { + count++ + } + } + } + return count +} + +// GenerateReview performs AI code review on diff files with Top-N strategy. +// It streams events (file_start, suggestion, file_end, summary, done) via callback. +func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) error { + // Read LLM config + config, err := GetLLMConfig(db) + if err != nil { + return err + } + + // Open repo + repo, err := OpenRepo(repoPath) + if err != nil { + return fmt.Errorf("open repo: %w", err) + } + + // Get diff files + files, err := GetDiffFiles(repo, base, head) + if err != nil { + return fmt.Errorf("get diff files: %w", err) + } + + if len(files) == 0 { + if callback != nil { + callback("summary", map[string]interface{}{"content": "没有检测到代码变更。"}) + callback("done", map[string]interface{}{"content": ""}) + } + return nil + } + + // Sort by change size (descending) + sort.Slice(files, func(i, j int) bool { + return countDiffLines(files[i].Patch) > countDiffLines(files[j].Patch) + }) + + totalFiles := len(files) + + // Apply Top-N (0 means analyze all) + if topN > 0 && topN < len(files) { + files = files[:topN] + } + + reviewedFiles := len(files) + + // Notify frontend of file count info + if callback != nil { + callback("progress", map[string]interface{}{ + "total_files": totalFiles, + "reviewed_files": reviewedFiles, + "top_n": topN, + }) + } + + // Review each file + var fileReviews []FileReview + for i, file := range files { + if callback != nil { + callback("file_start", map[string]interface{}{ + "file": file.Filename, + "index": i + 1, + "total": reviewedFiles, + }) + } + + changeLines := countDiffLines(file.Patch) + + // Truncate per-file diff if too large + patch := file.Patch + if len(patch) > 30000 { + patch = patch[:30000] + "\n\n... [diff truncated due to size]" + } + + // Build review prompt + prompt := fmt.Sprintf(`你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。 + +## 文件: %s +## 变更行数: +%d / -%d + +## Diff +%s + +请按以下 JSON 格式输出审查意见(直接输出 JSON 数组,不要包含 markdown 代码块标记): +[ + { + "severity": "critical 或 warning 或 info", + "description": "问题描述", + "suggestion": "建议的修改方案", + "code_example": "建议的代码(如有)" + } +] + +严重程度说明: +- critical: 严重问题(安全漏洞、数据丢失风险、崩溃风险) +- warning: 建议改进(性能问题、代码规范、可维护性) +- info: 提示信息(最佳实践、可选优化) + +如果代码没有问题,输出空数组 []。 +请用中文回复。`, file.Filename, changeLines/2+changeLines%2, changeLines/2, patch) + + messages := []goopenai.ChatCompletionMessage{ + {Role: goopenai.ChatMessageRoleUser, Content: prompt}, + } + + // Call LLM + fullResponse, err := ChatStream(config, messages, callback) + if err != nil { + // Continue with other files on error + if callback != nil { + callback("error", map[string]interface{}{ + "file": file.Filename, + "message": err.Error(), + }) + } + fileReviews = append(fileReviews, FileReview{ + FileName: file.Filename, + ChangeLines: changeLines, + Suggestions: nil, + RawReview: fmt.Sprintf("Error: %s", err.Error()), + }) + continue + } + + // Parse suggestions + jsonStr := extractJSON(fullResponse) + var suggestions []ReviewSuggestion + if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil { + // If parsing fails, try single object + var single ReviewSuggestion + if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil { + suggestions = []ReviewSuggestion{single} + } else { + // Fall back to raw text as info suggestion + suggestions = []ReviewSuggestion{{ + Severity: "info", + Description: fullResponse, + }} + } + } + + // Validate severity values + for i := range suggestions { + switch suggestions[i].Severity { + case "critical", "warning", "info": + // valid + default: + suggestions[i].Severity = "info" + } + } + + // Send suggestion events + for _, s := range suggestions { + content := s.Description + if s.Suggestion != "" { + content += "\n\n**建议修改:** " + s.Suggestion + } + if s.CodeExample != "" { + content += "\n\n```\n" + s.CodeExample + "\n```" + } + if callback != nil { + callback("suggestion", map[string]interface{}{ + "file": file.Filename, + "severity": s.Severity, + "content": content, + }) + } + } + + fileReviews = append(fileReviews, FileReview{ + FileName: file.Filename, + ChangeLines: changeLines, + Suggestions: suggestions, + RawReview: fullResponse, + }) + + if callback != nil { + callback("file_end", map[string]interface{}{"file": file.Filename}) + } + } + + // Generate summary + if len(fileReviews) > 0 { + if callback != nil { + callback("progress", map[string]interface{}{"step": "generating_summary"}) + } + + var reviewParts []string + for _, fr := range fileReviews { + suggestionText := "没有发现问题" + if len(fr.Suggestions) > 0 { + var parts []string + for _, s := range fr.Suggestions { + parts = append(parts, fmt.Sprintf("[%s] %s", s.Severity, s.Description)) + } + suggestionText = strings.Join(parts, "\n") + } + reviewParts = append(reviewParts, fmt.Sprintf("### %s (%d 行变更)\n%s", + fr.FileName, fr.ChangeLines, suggestionText)) + } + + summaryPrompt := fmt.Sprintf(`以下是多个文件的代码审查结果,请给出整体评估: + +%s + +请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记): +{ + "score": 7, + "overall": "总体评价(2-3 句话)", + "findings": "按严重程度排序的主要发现汇总", + "recommendations": "改进建议优先级列表" +} +请用中文回复。`, strings.Join(reviewParts, "\n\n")) + + messages := []goopenai.ChatCompletionMessage{ + {Role: goopenai.ChatMessageRoleUser, Content: summaryPrompt}, + } + + summaryResponse, err := ChatStream(config, messages, callback) + if err == nil { + jsonStr := extractJSON(summaryResponse) + var summary ReviewSummary + if json.Unmarshal([]byte(jsonStr), &summary) == nil { + summaryText := fmt.Sprintf("整体评分: %d/10\n\n%s", summary.Score, summary.Overall) + if summary.Findings != "" { + summaryText += "\n\n**主要发现:**\n" + summary.Findings + } + if summary.Recommendations != "" { + summaryText += "\n\n**改进建议:**\n" + summary.Recommendations + } + if callback != nil { + callback("summary", map[string]interface{}{"content": summaryText}) + } + } else { + // If parsing fails, send raw response as summary + if callback != nil { + callback("summary", map[string]interface{}{"content": summaryResponse}) + } + } + } else { + if callback != nil { + callback("error", map[string]interface{}{"message": "生成汇总失败: " + err.Error()}) + } + } + } + + if callback != nil { + callback("done", map[string]interface{}{"content": ""}) + } + + return nil +}