// Package ai 提供 OpenAI 兼容接口的调用,用于自动生成 "What's New" 更新说明。 package ai import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Config 是 AI 接入配置(管理员在后台填写)。 type Config struct { Enabled bool BaseURL string APIKey string Model string Language string // zh / en } // Ready 判断是否具备调用条件。 func (c Config) Ready() bool { return c.Enabled && strings.TrimSpace(c.BaseURL) != "" && strings.TrimSpace(c.Model) != "" } // Endpoint 归一化出 chat/completions 地址。 func (c Config) Endpoint() string { base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") switch { case base == "": return "" case strings.HasSuffix(base, "/chat/completions"): return base case strings.HasSuffix(base, "/v1"): return base + "/chat/completions" default: return base + "/v1/chat/completions" } } // CommitLine 是喂给模型的一条提交摘要。 type CommitLine struct { SHA string Author string Subject string } // SummaryInput 是一次推送的全部素材。 type SummaryInput struct { RepoName string Ref string Commits []CommitLine DiffStat string Additions int Deletions int } type chatRequest struct { Model string `json:"model"` Messages []chatMessage `json:"messages"` Temperature float64 `json:"temperature"` } type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } func systemPrompt(lang string) string { if lang == "en" { return `You are the changelog assistant of "gitcat", a self-hosted Git service for a small studio. Given the commits and change stats of one git push, write a short visitor-friendly "What's New" summary. Rules: 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.` } return `你是 "gitcat"(工作室自建 Git 服务)的更新日志助手。 请根据一次代码推送的提交信息与变更统计,写一段面向访问者的「What's New」更新说明。 要求:使用简体中文;最多 3 条要点,每条以「- 」开头并单独成行;归纳改动意图而不是照抄提交信息;不要编造未提及的内容;总字数控制在 120 字以内;不要输出标题或额外说明。` } // GenerateWhatsNew 调用模型生成更新说明。 func GenerateWhatsNew(ctx context.Context, cfg Config, in SummaryInput) (string, error) { if !cfg.Ready() { return "", fmt.Errorf("AI 未启用或配置不完整") } var sb strings.Builder fmt.Fprintf(&sb, "仓库:%s\n分支:%s\n提交数:%d\n", in.RepoName, in.Ref, len(in.Commits)) if in.Additions > 0 || in.Deletions > 0 { fmt.Fprintf(&sb, "代码行变化:+%d / -%d\n", in.Additions, in.Deletions) } if in.DiffStat != "" { sb.WriteString("变更文件统计:\n") sb.WriteString(truncate(in.DiffStat, 3000)) sb.WriteString("\n") } sb.WriteString("提交列表:\n") for i, c := range in.Commits { if i >= 40 { fmt.Fprintf(&sb, "…… 其余 %d 条提交已省略\n", len(in.Commits)-i) break } fmt.Fprintf(&sb, "- %s %s: %s\n", shortSHA(c.SHA), c.Author, c.Subject) } body, err := json.Marshal(chatRequest{ Model: cfg.Model, Messages: []chatMessage{ {Role: "system", Content: systemPrompt(cfg.Language)}, {Role: "user", Content: sb.String()}, }, Temperature: 0.3, }) if err != nil { return "", err } ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint(), bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") if cfg.APIKey != "" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } var parsed chatResponse if err := json.Unmarshal(raw, &parsed); err != nil { return "", fmt.Errorf("AI 返回内容无法解析(HTTP %d)", resp.StatusCode) } if parsed.Error != nil && parsed.Error.Message != "" { return "", fmt.Errorf("AI 接口错误:%s", parsed.Error.Message) } if resp.StatusCode >= 300 { return "", fmt.Errorf("AI 接口返回 HTTP %d", resp.StatusCode) } if len(parsed.Choices) == 0 { return "", fmt.Errorf("AI 未返回任何内容") } out := strings.TrimSpace(parsed.Choices[0].Message.Content) if out == "" { return "", fmt.Errorf("AI 返回内容为空") } return truncate(out, 2000), nil } // TestConnection 用一条极小的请求验证配置是否可用。 func TestConnection(ctx context.Context, cfg Config) (string, error) { cfg.Enabled = true return GenerateWhatsNew(ctx, cfg, SummaryInput{ RepoName: "gitcat-demo", Ref: "main", Commits: []CommitLine{ {SHA: "0123456789abcdef", Author: "gitcat", Subject: "add hello world page"}, }, DiffStat: " hello.txt | 1 +\n 1 file changed, 1 insertion(+)", }) } func shortSHA(s string) string { if len(s) > 7 { return s[:7] } return s } // truncate 按字符(而非字节)截断,避免切断多字节字符。 func truncate(s string, max int) string { r := []rune(s) if len(r) <= max { return s } cut := string(r[:max]) if i := strings.LastIndexAny(cut, "\n"); i > len(cut)/2 { cut = cut[:i] } return cut + "\n…" }