仰望星辰工作室

gitcat

gitcat/ internal/ai/ai.go 5.6 KB · 200 行 原始文件
1// Package ai 提供 OpenAI 兼容接口的调用,用于自动生成 "What's New" 更新说明。
2package ai
3
4import (
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "strings"
12 "time"
13)
14
15// Config 是 AI 接入配置(管理员在后台填写)。
16type Config struct {
17 Enabled bool
18 BaseURL string
19 APIKey string
20 Model string
21 Language string // zh / en
22}
23
24// Ready 判断是否具备调用条件。
25func (c Config) Ready() bool {
26 return c.Enabled && strings.TrimSpace(c.BaseURL) != "" && strings.TrimSpace(c.Model) != ""
27}
28
29// Endpoint 归一化出 chat/completions 地址。
30func (c Config) Endpoint() string {
31 base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
32 switch {
33 case base == "":
34 return ""
35 case strings.HasSuffix(base, "/chat/completions"):
36 return base
37 case strings.HasSuffix(base, "/v1"):
38 return base + "/chat/completions"
39 default:
40 return base + "/v1/chat/completions"
41 }
42}
43
44// CommitLine 是喂给模型的一条提交摘要。
45type CommitLine struct {
46 SHA string
47 Author string
48 Subject string
49}
50
51// SummaryInput 是一次推送的全部素材。
52type SummaryInput struct {
53 RepoName string
54 Ref string
55 Commits []CommitLine
56 DiffStat string
57 Additions int
58 Deletions int
59}
60
61type chatRequest struct {
62 Model string `json:"model"`
63 Messages []chatMessage `json:"messages"`
64 Temperature float64 `json:"temperature"`
65}
66
67type chatMessage struct {
68 Role string `json:"role"`
69 Content string `json:"content"`
70}
71
72type chatResponse struct {
73 Choices []struct {
74 Message struct {
75 Content string `json:"content"`
76 } `json:"message"`
77 } `json:"choices"`
78 Error *struct {
79 Message string `json:"message"`
80 } `json:"error"`
81}
82
83func systemPrompt(lang string) string {
84 if lang == "en" {
85 return `You are the changelog assistant of "gitcat", a self-hosted Git service for a small studio.
86Given the commits and change stats of one git push, write a short visitor-friendly "What's New" summary.
87Rules: English; at most 3 bullet points, each starting with "- "; summarize the intent instead of copying commit messages; do not invent features; keep it under 80 words; no headings, no extra commentary.`
88 }
89 return `你是 "gitcat"(工作室自建 Git 服务)的更新日志助手。
90请根据一次代码推送的提交信息与变更统计,写一段面向访问者的「What's New」更新说明。
91要求:使用简体中文;最多 3 条要点,每条以「- 」开头并单独成行;归纳改动意图而不是照抄提交信息;不要编造未提及的内容;总字数控制在 120 字以内;不要输出标题或额外说明。`
92}
93
94// GenerateWhatsNew 调用模型生成更新说明。
95func GenerateWhatsNew(ctx context.Context, cfg Config, in SummaryInput) (string, error) {
96 if !cfg.Ready() {
97 return "", fmt.Errorf("AI 未启用或配置不完整")
98 }
99 var sb strings.Builder
100 fmt.Fprintf(&sb, "仓库:%s\n分支:%s\n提交数:%d\n", in.RepoName, in.Ref, len(in.Commits))
101 if in.Additions > 0 || in.Deletions > 0 {
102 fmt.Fprintf(&sb, "代码行变化:+%d / -%d\n", in.Additions, in.Deletions)
103 }
104 if in.DiffStat != "" {
105 sb.WriteString("变更文件统计:\n")
106 sb.WriteString(truncate(in.DiffStat, 3000))
107 sb.WriteString("\n")
108 }
109 sb.WriteString("提交列表:\n")
110 for i, c := range in.Commits {
111 if i >= 40 {
112 fmt.Fprintf(&sb, "…… 其余 %d 条提交已省略\n", len(in.Commits)-i)
113 break
114 }
115 fmt.Fprintf(&sb, "- %s %s: %s\n", shortSHA(c.SHA), c.Author, c.Subject)
116 }
117
118 body, err := json.Marshal(chatRequest{
119 Model: cfg.Model,
120 Messages: []chatMessage{
121 {Role: "system", Content: systemPrompt(cfg.Language)},
122 {Role: "user", Content: sb.String()},
123 },
124 Temperature: 0.3,
125 })
126 if err != nil {
127 return "", err
128 }
129
130 ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
131 defer cancel()
132 req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint(), bytes.NewReader(body))
133 if err != nil {
134 return "", err
135 }
136 req.Header.Set("Content-Type", "application/json")
137 if cfg.APIKey != "" {
138 req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
139 }
140 resp, err := http.DefaultClient.Do(req)
141 if err != nil {
142 return "", err
143 }
144 defer resp.Body.Close()
145 raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
146 if err != nil {
147 return "", err
148 }
149 var parsed chatResponse
150 if err := json.Unmarshal(raw, &parsed); err != nil {
151 return "", fmt.Errorf("AI 返回内容无法解析(HTTP %d)", resp.StatusCode)
152 }
153 if parsed.Error != nil && parsed.Error.Message != "" {
154 return "", fmt.Errorf("AI 接口错误:%s", parsed.Error.Message)
155 }
156 if resp.StatusCode >= 300 {
157 return "", fmt.Errorf("AI 接口返回 HTTP %d", resp.StatusCode)
158 }
159 if len(parsed.Choices) == 0 {
160 return "", fmt.Errorf("AI 未返回任何内容")
161 }
162 out := strings.TrimSpace(parsed.Choices[0].Message.Content)
163 if out == "" {
164 return "", fmt.Errorf("AI 返回内容为空")
165 }
166 return truncate(out, 2000), nil
167}
168
169// TestConnection 用一条极小的请求验证配置是否可用。
170func TestConnection(ctx context.Context, cfg Config) (string, error) {
171 cfg.Enabled = true
172 return GenerateWhatsNew(ctx, cfg, SummaryInput{
173 RepoName: "gitcat-demo",
174 Ref: "main",
175 Commits: []CommitLine{
176 {SHA: "0123456789abcdef", Author: "gitcat", Subject: "add hello world page"},
177 },
178 DiffStat: " hello.txt | 1 +\n 1 file changed, 1 insertion(+)",
179 })
180}
181
182func shortSHA(s string) string {
183 if len(s) > 7 {
184 return s[:7]
185 }
186 return s
187}
188
189// truncate 按字符(而非字节)截断,避免切断多字节字符。
190func truncate(s string, max int) string {
191 r := []rune(s)
192 if len(r) <= max {
193 return s
194 }
195 cut := string(r[:max])
196 if i := strings.LastIndexAny(cut, "\n"); i > len(cut)/2 {
197 cut = cut[:i]
198 }
199 return cut + "\n…"
200}