diff --git a/README.md b/README.md index 11fb173..c9a5518 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,44 @@ 它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺: - ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。 -- 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini、Anthropic Claude、xAI Grok、DeepSeek、本地离线 Ollama、OpenAI** 等全系列模型。 +- 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、阿里通义千问、智谱 GLM、月之暗面 Kimi、本地 Ollama、OpenAI** 等全系列模型。 - 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。 - 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。 +- 📊 **Token 消耗与费用实时估算**:实时捕获并统计输入/输出 Token 消耗,内置 2026 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。 - 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 - 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 --- +## 🖥️ 终端效果演示 + +```text +$ git air + +[git-air] 代码评审中... (Engine: gemini / gemini-3.7-flash) +───────────────────────────────────────────────────────────────── +#### 变更概述 +在用户鉴权模块中引入了 Redis 缓存逻辑,并调整了上下文超时传递机制。 + +#### 详细审查意见 +- [BLOCKER] internal/service/user.go:45 - 严重安全隐患: 循环体内直接拼接原生 SQL 字符串,存在 SQL 注入风险。 + // 建议修复方案: + db.Where("username = ?", inputName).First(&user) + +- [WARNING] internal/service/user.go:82 - 潜在风险: 静默丢弃了 Redis 连接异常,当缓存宕机时可能造成缓存击穿。 + +- [WARNING] internal/service/user.go:103 - 并发隐患: 提前 return 时漏释放互斥锁(Mutex Leak),高并发下极易引发死锁。 + +#### 评审结论 +- 结论: [REJECT] +- 评分: 60 / 100 + +📊 Token: 输入 1,243 / 输出 412 | ≈ $0.0025 +───────────────────────────────────────────────────────────────── +``` + +--- + ## 📦 安装方式 ### 方式 1:通过 Go 一键安装(推荐) @@ -112,9 +142,12 @@ git air config set --provider ollama --model qwen2.5-coder # 12. OpenAI 官方 (GPT-4o / o3-mini) git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini + +# 13. 自定义 Token 计费费率 (可选:覆盖内置价格表,单位: 美元/1M Tokens) +git air config set --price-input 0.75 --price-output 3.75 ``` -查看当前配置状态: +查看当前配置状态(含自定义费率与脱敏 Key): ```bash git air config get ``` @@ -148,7 +181,9 @@ git air --provider deepseek --model deepseek-chat ```bash git air hook install ``` -以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒! +- **自动门禁阻断**:当审查发现 `[BLOCKER]` 严重缺陷或 `[REJECT]` 评审结论时,`git-air` 会自动以非零退出码**直接拦截并阻断本次 `git commit`**,死守代码质量底线! +- **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`; +- **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。 如需卸载钩子: ```bash @@ -160,7 +195,7 @@ git air hook uninstall ## ⚙️ 配置文件说明 `git-air` 支持多层配置覆盖,优先级从高到低为: -1. **命令行 Flags**(如 `--key`, `--model`, `--provider`) +1. **命令行 Flags**(如 `--key`, `--model`, `--provider`, `--strict`, `--no-block`) 2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`) 3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`) 4. **全局用户配置**(`~/.git-air/config.yaml`) @@ -181,6 +216,10 @@ model: "gemini-3.7-flash" # 自定义基础提示词 (可选,留空则使用默认资深架构师准则) # custom_prompt: "" + +# 自定义模型单价 (可选,单位: 美元/1M Tokens,用于精准核算自定义中转或企业私有接口费用) +# price_input: 0.75 +# price_output: 3.75 ``` --- @@ -199,6 +238,27 @@ model: "gemini-3.7-flash" --- +## 🛡️ 自定义忽略审查(`.airignore`) + +除了内置自动忽略的锁文件与二进制外,你还可以在仓库根目录创建 `.airignore` 文件,自定义排除不需要 AI 评审的文件或目录: + +```gitignore +# 忽略所有文档与原型设计 +docs/ +*.md + +# 忽略测试 Mock 与假数据 +tests/mock/ +testdata/ + +# 忽略私密配置文件 +*.env +secrets.yaml +``` + +--- + ## 📄 开源许可证 本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request! + diff --git a/README_EN.md b/README_EN.md index f30f877..d63d77e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -44,14 +44,44 @@ While there are great cloud/CI-based PR review bots, **`git-air` is designed to Completely isolated from your chat history, it acts like a **grumpy but sharp senior architect sitting beside your desk**, ruthlessly inspecting only raw code changes (`git diff`): - ⚡ **Git-Native Simplicity**: No workflow changes needed. Just run `git air` in your terminal for instant feedback. -- 🧠 **Universal Multi-Model Support**: Pre-configured support for **Google Gemini, Anthropic Claude, xAI Grok, DeepSeek, Local Ollama, OpenAI**, and any OpenAI-compatible custom endpoint. +- 🧠 **Universal Multi-Model Support**: Pre-configured support for **Google Gemini 3.x, Anthropic Claude, xAI Grok, DeepSeek, Qwen, Zhipu GLM, Moonshot Kimi, Local Ollama, OpenAI**, and any OpenAI-compatible endpoint. - 🛡️ **Smart Noise Reduction**: Automatically strips out `go.sum`, `package-lock.json`, `*.pb.go`, and lockfiles to save tokens and eliminate review loops. - 🎯 **Strict Senior Architect Persona**: Zero corporate fluff. Directly flags concurrency deadlocks, SQL injections, nil dereferences, and resource leaks with copy-paste code patches. +- 📊 **Real-time Token & Cost Estimation**: Accurately tracks prompt/completion token consumption with built-in official pricing rates (local offline models automatically marked free). - 📋 **Team Rules Support (`.airules`)**: Drop an `.airules` file in your repo root, and the AI will prioritize your team-specific guidelines. - 🪝 **One-Click Pre-commit Hook**: Mounts as a local Git hook in one command to block dangerous bugs before code is committed. --- +## 🖥️ Terminal Preview + +```text +$ git air + +[git-air] Reviewing code... (Engine: gemini / gemini-3.7-flash) +───────────────────────────────────────────────────────────────── +#### Change Summary +Added Redis caching layer to user authentication and refined context propagation. + +#### Detailed Findings +- [BLOCKER] internal/service/user.go:45 - Critical Vulnerability: Direct SQL string concatenation inside loop. Potential SQL injection. + // Suggested Fix: + db.Where("username = ?", inputName).First(&user) + +- [WARNING] internal/service/user.go:82 - Potential Risk: Silently discarding Redis error. May lead to cache stampede. + +- [WARNING] internal/service/user.go:103 - Concurrency Issue: Mutex unlock skipped on early return branch. High risk of deadlock. + +#### Verdict +- Status: [REJECT] +- Score: 60 / 100 + +📊 Token: Input 1,243 / Output 412 | ≈ $0.0025 +───────────────────────────────────────────────────────────────── +``` + +--- + ## 📦 Installation ### Option 1: Via Go Install (Recommended) @@ -111,9 +141,12 @@ git air config set --provider ollama --model qwen2.5-coder # 12. OpenAI Official (GPT-4o / o3-mini) git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini + +# 13. Custom Token Pricing (Optional: override default rates, USD/1M tokens) +git air config set --price-input 0.75 --price-output 3.75 ``` -Check current configuration: +Check current configuration (including masked keys and custom rates): ```bash git air config get ``` @@ -147,7 +180,9 @@ Install the hook inside any Git repository: ```bash git air hook install ``` -From now on, whenever you run `git commit`, `git-air` will automatically perform a code review in your terminal and warn you of any blocker issues! +- **Automated Commit Gate**: When `git-air` flags any `[BLOCKER]` issue or outputs a `[REJECT]` verdict, it automatically exits with a non-zero status code, **directly intercepting and blocking `git commit`**! +- **Strict Mode (`--strict`)**: To block commits even on `[WARNING]` issues, use `git air --strict`; +- **Bypass Interception**: If you urgently need to bypass the check, use standard Git `git commit --no-verify` or pass `git air --no-block`. To uninstall: ```bash @@ -159,11 +194,29 @@ git air hook uninstall ## ⚙️ Configuration Hierarchy `git-air` searches for configurations in the following priority order: -1. **CLI Flags** (`--key`, `--model`, `--provider`, `--prompt`) +1. **CLI Flags** (`--key`, `--model`, `--provider`, `--prompt`, `--price-input`, `--price-output`, `--strict`, `--no-block`) 2. **Environment Variables** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`) 3. **Project Config** (`./config.yaml` or `./.git-air.yaml` in repo root) 4. **Global User Config** (`~/.git-air/config.yaml`) +### `config.yaml` Example Template: +```yaml +# Provider: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom +provider: "gemini" +api_key: "YOUR_API_KEY_HERE" +model: "gemini-3.7-flash" + +# Custom Base URL (Optional) +# base_url: "https://api.deepseek.com/v1" + +# Custom System Prompt (Optional) +# custom_prompt: "" + +# Custom Token Pricing (Optional, in USD per 1M tokens) +# price_input: 0.75 +# price_output: 3.75 +``` + --- ## 📋 Custom Team Rules (`.airules`) @@ -180,6 +233,27 @@ Create an `.airules` file in your repository root. `git-air` will automatically --- +## 🛡️ Custom Ignore Rules (`.airignore`) + +In addition to built-in ignored lockfiles and binaries, create an `.airignore` file in your repository root to exclude specific paths from AI review: + +```gitignore +# Ignore documentation & design specs +docs/ +*.md + +# Ignore test mocks and fixtures +tests/mock/ +testdata/ + +# Ignore sensitive config files +*.env +secrets.yaml +``` + +--- + ## 📄 License This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome! + diff --git a/README_JA.md b/README_JA.md index 39a9724..5d930c0 100644 --- a/README_JA.md +++ b/README_JA.md @@ -44,14 +44,44 @@ GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air` チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします: - ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。 -- 🧠 **主要モデルを完全網羅**:**Google Gemini、Anthropic Claude、xAI Grok、DeepSeek、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。 +- 🧠 **主要モデルを完全網羅**:**Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、Qwen、智譜 GLM、Kimi、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。 - 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。 - 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。 +- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。 - 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。 - 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録。危険なコードのコミットを未然にブロックします。 --- +## 🖥️ ターミナル実行イメージ + +```text +$ git air + +[git-air] コードレビュー中... (Engine: gemini / gemini-3.7-flash) +───────────────────────────────────────────────────────────────── +#### 変更概要 +ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。 + +#### 詳細レビュー結果 +- [BLOCKER] internal/service/user.go:45 - 重大なセキュリティ脆弱性: ループ内で生SQL文字列を直接結合しています。SQLインジェクションのリスクがあります。 + // 推奨修正コード: + db.Where("username = ?", inputName).First(&user) + +- [WARNING] internal/service/user.go:82 - 潜在的リスク: Redis 接続エラーを握りつぶしています。キャッシュ障害時にDBへ負荷が集中します。 + +- [WARNING] internal/service/user.go:103 - 並行処理の欠陥: 早期リターン時に Mutex の Unlock を忘れています(Mutex Leak)。デッドロックの危険性があります。 + +#### 結論 +- 判定: [REJECT] +- スコア: 60 / 100 + +📊 Token: 入力 1,243 / 出力 412 | ≈ $0.0025 +───────────────────────────────────────────────────────────────── +``` + +--- + ## 📦 インストール ### 方法 1: Go Install(推奨) @@ -111,9 +141,12 @@ git air config set --provider ollama --model qwen2.5-coder # 12. OpenAI 公式 (GPT-4o / o3-mini) git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini + +# 13. カスタム Token 料金設定 (任意:組み込み料金表を上書き、単位: 米ドル/1M Tokens) +git air config set --price-input 0.75 --price-output 3.75 ``` -現在の設定を確認: +現在の設定を確認(カスタム料金やマスク化Keyを含む): ```bash git air config get ``` @@ -147,7 +180,9 @@ Git リポジトリ直下で以下を実行: ```bash git air hook install ``` -以降、`git commit` を実行するたびにターミナル上で自動レビューが走り、重大な欠陥があればコミット前に警告を出してくれます! +- **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します! +- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用; +- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。 フックを解除する場合: ```bash @@ -158,11 +193,29 @@ git air hook uninstall ## ⚙️ 設定の優先順位 -1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`) +1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--price-input`, `--price-output`, `--strict`, `--no-block`) 2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`) 3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`) 4. **グローバル設定** (`~/.git-air/config.yaml`) +### `config.yaml` 設定ファイル例: +```yaml +# プロバイダー: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom +provider: "gemini" +api_key: "YOUR_API_KEY_HERE" +model: "gemini-3.7-flash" + +# カスタム API エンドポイント (任意) +# base_url: "https://api.deepseek.com/v1" + +# カスタム System Prompt (任意) +# custom_prompt: "" + +# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens) +# price_input: 0.75 +# price_output: 3.75 +``` + --- ## 📋 チーム規約のカスタマイズ(`.airules`) @@ -179,6 +232,26 @@ git air hook uninstall --- +## 🛡️ カスタム除外設定(`.airignore`) + +組み込みの自動除外(ロックファイルやバイナリ)に加え、リポジトリ直下に `.airignore` を配置することで、任意のファイルやディレクトリをAIレビューから除外できます: + +```gitignore +# ドキュメントや設計書を除外 +docs/ +*.md + +# テストモックやダミーデータを除外 +tests/mock/ +testdata/ + +# 機密設定ファイルを除外 +*.env +secrets.yaml +``` + +--- + ## 📄 ライセンス 本プロジェクトは [MIT ライセンス](LICENSE) のもとで公開されています。Issue や Pull Request も大歓迎です! diff --git a/cmd/config.go b/cmd/config.go index 64a304b..9009024 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -5,18 +5,20 @@ import ( "os" "path/filepath" - "github.com/unclesam-ly/git-air/internal/ui" "github.com/spf13/cobra" + "github.com/unclesam-ly/git-air/internal/ui" "gopkg.in/yaml.v3" ) // AppConfig 全局配置结构体 type AppConfig struct { - Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom - APIKey string `yaml:"api_key"` // API Key - BaseURL string `yaml:"base_url"` // 自定义请求地址 - Model string `yaml:"model"` // 模型名称 - CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt + Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom + APIKey string `yaml:"api_key"` // API Key + BaseURL string `yaml:"base_url"` // 自定义请求地址 + Model string `yaml:"model"` // 模型名称 + CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt + PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选) + PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选) } func getConfigPath() (string, error) { @@ -126,6 +128,12 @@ var configSetCmd = &cobra.Command{ if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { cfg.CustomPrompt = pr } + if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 { + cfg.PriceInput = pi + } + if po, _ := cmd.Flags().GetFloat64("price-output"); po > 0 { + cfg.PriceOutput = po + } if err := SaveConfig(cfg); err != nil { ui.PrintError("保存配置失败: %v", err) return @@ -160,6 +168,11 @@ var configGetCmd = &cobra.Command{ hasPrompt = "是 (已自定义)" } fmt.Printf("CustomPrompt: %s\n", hasPrompt) + if cfg.PriceInput > 0 || cfg.PriceOutput > 0 { + fmt.Printf("CustomPrice: 输入 $%.4f/1M, 输出 $%.4f/1M (已自定义覆盖)\n", cfg.PriceInput, cfg.PriceOutput) + } else { + fmt.Printf("CustomPrice: 默认 (跟随官方最新价格表)\n") + } }, } @@ -169,6 +182,8 @@ func init() { configSetCmd.Flags().StringP("model", "m", "", "模型名称") configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址") configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt") + configSetCmd.Flags().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格") + configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格") configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configGetCmd) diff --git a/cmd/root.go b/cmd/root.go index 56927d7..d07da68 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,12 +3,13 @@ package cmd import ( "context" "os" + "strings" + "github.com/spf13/cobra" "github.com/unclesam-ly/git-air/internal/git" "github.com/unclesam-ly/git-air/internal/llm" "github.com/unclesam-ly/git-air/internal/reviewer" "github.com/unclesam-ly/git-air/internal/ui" - "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ @@ -53,10 +54,12 @@ var rootCmd = &cobra.Command{ // 3. 初始化大模型客户端 llmClient, err := llm.NewClient(llm.Config{ - Provider: cfg.Provider, - APIKey: cfg.APIKey, - BaseURL: cfg.BaseURL, - Model: cfg.Model, + Provider: cfg.Provider, + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + PriceInput: cfg.PriceInput, + PriceOutput: cfg.PriceOutput, }) if err != nil { ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err) @@ -76,15 +79,43 @@ var rootCmd = &cobra.Command{ // 6. 打印开场横幅并开始流式评审 ui.PrintBanner(cfg.Provider, cfg.Model) printer := ui.NewStreamPrinter() - _, err = rev.Execute(ctx, diff, true, func(chunk string) { + var reviewOutput strings.Builder + _, usage, err := rev.Execute(ctx, diff, true, func(chunk string) { + reviewOutput.WriteString(chunk) printer.PrintChunk(chunk) }) if err != nil { ui.PrintError("\n评审过程中发生错误: %v", err) - return + os.Exit(1) + } + + // 7. 打印 Token 消耗统计 + if usage != nil { + ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost()) } ui.PrintFooter() + + // 8. 门禁阻断检测 (Pre-commit 拦截逻辑) + outText := reviewOutput.String() + hasBlocker := strings.Contains(outText, "[BLOCKER]") || strings.Contains(outText, "[CRITICAL]") + hasReject := strings.Contains(outText, "[REJECT]") + hasWarning := strings.Contains(outText, "[WARNING]") || strings.Contains(outText, "[WARN]") + + strict, _ := cmd.Flags().GetBool("strict") + noBlock, _ := cmd.Flags().GetBool("no-block") + + if !noBlock { + if hasBlocker || hasReject { + ui.PrintError("[git-air] 审查未通过:检测到 [BLOCKER] 严重缺陷或 [REJECT] 结论,已拦截提交!") + ui.PrintError("修复上述问题后再提交;如需临时跳过可执行 'git commit --no-verify' 或加上 '--no-block'") + os.Exit(1) + } else if strict && hasWarning { + ui.PrintError("[git-air] 严格模式(--strict)生效:检测到 [WARNING] 警告,已拦截提交!") + ui.PrintError("请优化上述警告或去掉 --strict 标志。") + os.Exit(1) + } + } }, } @@ -107,4 +138,6 @@ func init() { rootCmd.Flags().String("prompt", "", "临时指定 System Prompt") rootCmd.Flags().Bool("cached", false, "显式审查暂存区(git add)代码") rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码") + rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出") + rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断") } diff --git a/config.yaml b/config.yaml index 40acb06..e4800b5 100644 --- a/config.yaml +++ b/config.yaml @@ -30,4 +30,11 @@ model: "gemini-3.7-flash" # base_url: "" # 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) -custom_prompt: "" \ No newline at end of file +custom_prompt: "" + +# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens) +# 默认情况下 git-air 内置了各主流模型官方最新价格表; +# 大模型厂商价格可能动态调整,请以实际价格为准 +# 若你的中转平台价格有变或使用私有计费,可在此自定义覆盖: +# price_input: 0.75 # 每 1M 输入 Token 美元单价 +# price_output: 3.75 # 每 1M 输出 Token 美元单价 \ No newline at end of file diff --git a/internal/git/diff.go b/internal/git/diff.go index 7f522f5..989831a 100644 --- a/internal/git/diff.go +++ b/internal/git/diff.go @@ -43,6 +43,9 @@ func GetDiff(ctx context.Context, args ...string) (string, error) { return "", ErrNotGitRepo } + repoRoot, _ := GetRepoRoot(ctx) + customIgnores := LoadAirIgnore(repoRoot) + var cmdArgs []string if len(args) == 0 { // 1. 默认先提取暂存区代码 (准备 commit 的代码) @@ -51,7 +54,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) { return "", err } if strings.TrimSpace(stagedDiff) != "" { - return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil + return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil } // 2. 如果暂存区为空,回退提取工作区未暂存的修改 cmdArgs = []string{} @@ -64,7 +67,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) { return "", err } - filteredDiff := FilterDiff(rawDiff) + filteredDiff := FilterDiff(rawDiff, customIgnores...) if strings.TrimSpace(filteredDiff) == "" { return "", ErrEmptyDiff } diff --git a/internal/git/filter.go b/internal/git/filter.go index 19bbd4d..22745a4 100644 --- a/internal/git/filter.go +++ b/internal/git/filter.go @@ -1,13 +1,14 @@ package git import ( + "bufio" + "os" "path/filepath" - "regexp" "strings" ) // 默认需要忽略的文件名与后缀列表 -var ignoredPatterns = []string{ +var defaultIgnoredPatterns = []string{ // 依赖与锁文件 "go.sum", "package-lock.json", @@ -36,19 +37,45 @@ var ignoredPatterns = []string{ "*.pdf", } -// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略 -func isIgnoredFile(filePath string) bool { - cleanPath := filepath.ToSlash(filePath) +// LoadAirIgnore 从仓库根目录加载 .airignore 文件中的自定义忽略规则 +func LoadAirIgnore(repoRoot string) []string { + if repoRoot == "" { + return nil + } + ignoreFile := filepath.Join(repoRoot, ".airignore") + file, err := os.Open(ignoreFile) + if err != nil { + return nil + } + defer file.Close() + + var patterns []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + trimmed := strings.TrimSpace(scanner.Text()) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + patterns = append(patterns, trimmed) + } + return patterns +} + +// isIgnoredFile 判断文件路径是否命中忽略规则(内置规则 + 用户自定义规则) +func isIgnoredFile(filePath string, customPatterns []string) bool { + cleanPath := filepath.ToSlash(strings.TrimSpace(filePath)) baseName := filepath.Base(cleanPath) - for _, pattern := range ignoredPatterns { - // 精确匹配文件名(如 go.sum) - if pattern == baseName { + // 1. 先匹配用户自定义规则 (.airignore) + for _, pattern := range customPatterns { + if matchPattern(pattern, cleanPath, baseName) { return true } + } - // 通配符匹配(如 *.pb.go) - if matched, _ := filepath.Match(pattern, baseName); matched { + // 2. 再匹配内置默认规则 + for _, pattern := range defaultIgnoredPatterns { + if matchPattern(pattern, cleanPath, baseName) { return true } } @@ -56,10 +83,41 @@ func isIgnoredFile(filePath string) bool { return false } -var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`) +// matchPattern 单条规则智能匹配(支持目录/、通配符*、精确匹配) +func matchPattern(pattern, cleanPath, baseName string) bool { + pattern = filepath.ToSlash(strings.TrimSpace(pattern)) + if pattern == "" { + return false + } -// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块 -func FilterDiff(rawDiff string) string { + // 目录规则:如 "docs/" 或 "vendor/" + if strings.HasSuffix(pattern, "/") { + dirPrefix := strings.TrimSuffix(pattern, "/") + if strings.HasPrefix(cleanPath, dirPrefix+"/") || cleanPath == dirPrefix { + return true + } + } + + // 精确匹配文件名或相对路径 + if pattern == baseName || pattern == cleanPath { + return true + } + + // 通配符匹配文件名(如 *.pb.go) + if matched, _ := filepath.Match(pattern, baseName); matched { + return true + } + + // 通配符匹配全路径(如 mock/*.go) + if matched, _ := filepath.Match(pattern, cleanPath); matched { + return true + } + + return false +} + +// FilterDiff 过滤掉 diff 中命中忽略规则(如锁文件、生成的代码、.airignore 排除项)的文件区块 +func FilterDiff(rawDiff string, customPatterns ...string) string { if strings.TrimSpace(rawDiff) == "" { return "" } @@ -85,7 +143,7 @@ func FilterDiff(rawDiff string) string { parts := strings.Fields(firstLine) if len(parts) >= 2 { filePath := strings.TrimPrefix(parts[1], "b/") - if isIgnoredFile(filePath) { + if isIgnoredFile(filePath, customPatterns) { continue // 命中忽略规则,跳过这个文件的 diff } } diff --git a/internal/git/filter_test.go b/internal/git/filter_test.go new file mode 100644 index 0000000..5240ede --- /dev/null +++ b/internal/git/filter_test.go @@ -0,0 +1,112 @@ +package git + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsIgnoredFile(t *testing.T) { + customPatterns := []string{ + "docs/", + "tests/mock/*.json", + "secrets.env", + } + + tests := []struct { + filePath string + expected bool + }{ + // 默认内置忽略规则 + {"go.sum", true}, + {"package-lock.json", true}, + {"api/user.pb.go", true}, + {"web/dist/app.min.js", true}, + {"assets/logo.png", true}, + + // 用户自定义 .airignore 规则 + {"docs/readme.md", true}, + {"docs/api/v1.md", true}, + {"tests/mock/user.json", true}, + {"secrets.env", true}, + + // 不应被忽略的正常业务代码 + {"cmd/root.go", false}, + {"internal/service/user.go", false}, + {"pkg/utils/helper.go", false}, + } + + for _, tt := range tests { + got := isIgnoredFile(tt.filePath, customPatterns) + if got != tt.expected { + t.Errorf("isIgnoredFile(%q) = %v, expected %v", tt.filePath, got, tt.expected) + } + } +} + +func TestLoadAirIgnore(t *testing.T) { + tempDir, err := os.MkdirTemp("", "git-air-test-*") + if err != nil { + t.Fatalf("创建临时目录失败: %v", err) + } + defer os.RemoveAll(tempDir) + + content := `# 忽略文档 + docs/ + # 忽略临时文件 + *.tmp + *.log + ` + ignoreFile := filepath.Join(tempDir, ".airignore") + if err := os.WriteFile(ignoreFile, []byte(content), 0644); err != nil { + t.Fatalf("写入 .airignore 失败: %v", err) + } + + patterns := LoadAirIgnore(tempDir) + expected := []string{"docs/", "*.tmp", "*.log"} + + if len(patterns) != len(expected) { + t.Fatalf("LoadAirIgnore 返回数量不符: got %v, expected %v", patterns, expected) + } + + for i, p := range patterns { + if p != expected[i] { + t.Errorf("Pattern[%d] = %q, expected %q", i, p, expected[i]) + } + } +} + +func TestFilterDiff(t *testing.T) { + rawDiff := `diff --git a/go.sum b/go.sum + index 111..222 100644 + --- a/go.sum + +++ b/go.sum + @@ -1 +1 @@ + +some-package v1.0.0 h1:... + diff --git a/internal/service/user.go b/internal/service/user.go + index 333..444 100644 + --- a/internal/service/user.go + +++ b/internal/service/user.go + @@ -1 +1 @@ + +func HandleUser() {} + diff --git a/docs/spec.md b/docs/spec.md + index 555..666 100644 + --- a/docs/spec.md + +++ b/docs/spec.md + @@ -1 +1 @@ + +# API Spec + ` + customPatterns := []string{"docs/"} + filtered := FilterDiff(rawDiff, customPatterns...) + + if strings.Contains(filtered, "go.sum") { + t.Errorf("FilterDiff 应当过滤掉 go.sum") + } + if strings.Contains(filtered, "docs/spec.md") { + t.Errorf("FilterDiff 应当过滤掉 docs/spec.md") + } + if !strings.Contains(filtered, "internal/service/user.go") { + t.Errorf("FilterDiff 应当保留正常的业务代码 internal/service/user.go") + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 26f2ef3..0026cf7 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -12,16 +12,20 @@ import ( // Config 大模型统一配置 type Config struct { - Provider string // "gemini", "deepseek", "openai", "ollama", "custom" - APIKey string // 密钥 (Ollama 本地可填任意字符或留空) - BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值) - Model string // 模型名称 (非必填,会自动设置默认主力模型) + Provider string // "gemini", "deepseek", "openai", "ollama", "custom" + APIKey string // 密钥 (Ollama 本地可填任意字符或留空) + BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值) + Model string // 模型名称 (非必填,会自动设置默认主力模型) + PriceInput float64 // 用户自定义每 1M 输入 Token 美元价格 (可选,覆盖内置价格表) + PriceOutput float64 // 用户自定义每 1M 输出 Token 美元价格 (可选,覆盖内置价格表) } // Client 统一大模型客户端 type Client struct { - client *openai.Client - model string + client *openai.Client + model string + priceInput float64 + priceOutput float64 } // NewClient 初始化客户端 @@ -227,8 +231,10 @@ func NewClient(cfg Config) (*Client, error) { clientConfig.BaseURL = baseURL return &Client{ - client: openai.NewClientWithConfig(clientConfig), - model: model, + client: openai.NewClientWithConfig(clientConfig), + model: model, + priceInput: cfg.PriceInput, + priceOutput: cfg.PriceOutput, }, nil } @@ -254,8 +260,122 @@ func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, return resp.Choices[0].Message.Content, nil } -// ReviewStream 流式逐字返回评审结果(打字机效果) -func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error { +// Usage 记录一次调用的 Token 消耗 +type Usage struct { + InputTokens int + OutputTokens int + Model string + Provider string + CustomPriceInput float64 // 用户自定义输入单价(每 1M tokens 美元,>0 优先使用) + CustomPriceOutput float64 // 用户自定义输出单价(每 1M tokens 美元,>0 优先使用) +} + +// reviewPriceTable 各模型每 1M token 的美元单价(输入/输出) +// 数据来源:各厂商官方定价页面,更新于 2026-08 +// 如实际价格有变动,可在 config.yaml 中通过 price_input/price_output 字段自定义覆盖 +var reviewPriceTable = map[string][2]float64{ + + // ── Google Gemini ───────────────────────────────────────────── + // 3.x 系列(当前主力) + "gemini-3.7-flash": {0.75, 3.75}, // 推介价至 2026-12-31,之后 1.50/7.50 + "gemini-3.6-flash": {1.50, 7.50}, + "gemini-3.5-flash": {1.50, 9.00}, + "gemini-3.5-flash-lite": {0.30, 2.50}, + "gemini-3.1-flash-lite": {0.25, 1.50}, + "gemini-3.1-pro": {2.00, 12.00}, + // 2.5 系列(2026-10 退役) + "gemini-2.5-pro": {1.25, 10.00}, + "gemini-2.5-flash": {0.30, 2.50}, + "gemini-2.5-flash-lite": {0.10, 0.40}, + // 旧款(兼容) + "gemini-2.0-flash": {0.10, 0.40}, + + // ── DeepSeek ───────────────────────────────────────────────── + // 注意:DeepSeek 已启用峰谷定价,以下为非高峰标准价 + "deepseek-v4-flash": {0.22, 0.66}, + "deepseek-chat": {0.22, 0.66}, // V4-Flash 别名 + "deepseek-reasoner": {0.55, 2.19}, + + // ── Anthropic Claude ────────────────────────────────────────── + "claude-opus-5": {5.00, 25.00}, + "claude-sonnet-5": {2.00, 10.00}, // 推介价至 2026-08-31 + "anthropic/claude-sonnet-5": {2.00, 10.00}, + "claude-haiku-4-5": {1.00, 5.00}, + "anthropic/claude-haiku-4-5": {1.00, 5.00}, + // 旧款(兼容) + "anthropic/claude-3.7-sonnet": {3.00, 15.00}, + "anthropic/claude-3.5-sonnet": {3.00, 15.00}, + "anthropic/claude-3.5-haiku": {0.80, 4.00}, + + // ── xAI Grok ───────────────────────────────────────────────── + "grok-4.6": {2.00, 6.00}, // >200k tokens 翻倍 + "grok-4.3": {1.25, 2.50}, + "grok-2-latest": {2.00, 6.00}, // 旧款映射 + + // ── OpenAI ─────────────────────────────────────────────────── + "gpt-5.6-sol": {5.00, 30.00}, + "gpt-5.6-terra": {2.00, 12.00}, + "gpt-5.6-luna": {0.20, 1.20}, + "gpt-4o": {2.50, 10.00}, // 旧款保留 + "gpt-4o-mini": {0.15, 0.60}, + "o3-mini": {1.10, 4.40}, + + // ── SiliconFlow(聚合平台,按 DeepSeek V4 主力估算)──────────── + "deepseek-ai/DeepSeek-V3": {0.22, 0.66}, + "deepseek-ai/DeepSeek-R1": {0.55, 2.19}, + + // ── Qwen / 阿里通义千问 ─────────────────────────────────────── + "qwen-3.8-max": {2.00, 6.00}, + "qwen-max": {2.00, 6.00}, + "qwen-plus": {0.40, 1.20}, + "qwen-3.7-flash": {0.03, 0.13}, + + // ── Kimi / Moonshot AI ──────────────────────────────────────── + "kimi-k3": {3.00, 15.00}, + "moonshot-v1-8k": {0.95, 4.00}, + "moonshot-v1-32k": {1.90, 8.00}, + "moonshot-v1-128k": {3.50, 15.00}, + + // ── 智谱 GLM ───────────────────────────────────────────────── + "glm-5.3": {1.40, 4.40}, + "glm-5.2": {1.40, 4.40}, + "glm-4-plus": {1.40, 4.40}, + + // ── Doubao / 字节跳动 ───────────────────────────────────────── + "doubao-seed-2.1-pro": {0.88, 4.42}, + "doubao-pro-32k": {0.88, 4.42}, + + // ── Groq(超快推理,按量计费)──────────────────────────────── + "llama-3.3-70b-versatile": {0.59, 0.79}, + "llama-3.1-8b-instant": {0.05, 0.08}, + + // ── 本地模型(完全免费)────────────────────────────────────── + "ollama": {0, 0}, + "local-model": {0, 0}, +} + +// EstimateCost 估算费用(美元),返回 -1 表示无定价数据 +func (u *Usage) EstimateCost() float64 { + // 1. 优先使用用户在 config.yaml 中配置的自定义单价(每 1M tokens 美元) + if u.CustomPriceInput > 0 || u.CustomPriceOutput > 0 { + inputCost := float64(u.InputTokens) / 1_000_000 * u.CustomPriceInput + outputCost := float64(u.OutputTokens) / 1_000_000 * u.CustomPriceOutput + return inputCost + outputCost + } + + // 2. 回退使用内置官方模型价格表 + prices, ok := reviewPriceTable[u.Model] + if !ok { + return -1 + } + // Ollama / 本地模型 price = 0,直接返回 0 + inputCost := float64(u.InputTokens) / 1_000_000 * prices[0] + outputCost := float64(u.OutputTokens) / 1_000_000 * prices[1] + return inputCost + outputCost +} + +// ReviewStream 流式逐字返回评审结果(打字机效果),同时返回 Token 使用量 +func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) (*Usage, error) { stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ Model: c.model, Messages: []openai.ChatCompletionMessage{ @@ -264,18 +384,27 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on }, Temperature: 0.2, Stream: true, + StreamOptions: &openai.StreamOptions{ + IncludeUsage: true, + }, }) if err != nil { - return fmt.Errorf("创建流式请求失败: %w", err) + return nil, fmt.Errorf("创建流式请求失败: %w", err) } if stream != nil { defer stream.Close() } + usage := &Usage{ + Model: c.model, + CustomPriceInput: c.priceInput, + CustomPriceOutput: c.priceOutput, + } + for { select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -284,8 +413,15 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on break } if err != nil { - return fmt.Errorf("流式读取异常: %w", err) + return nil, fmt.Errorf("流式读取异常: %w", err) } + + // 捕获最后一个 chunk 中携带的 Usage 信息 + if response.Usage.TotalTokens > 0 { + usage.InputTokens = response.Usage.PromptTokens + usage.OutputTokens = response.Usage.CompletionTokens + } + if len(response.Choices) > 0 { chunk := response.Choices[0].Delta.Content if chunk != "" { @@ -294,5 +430,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on } } - return nil + return usage, nil } diff --git a/internal/reviewer/prompt.go b/internal/reviewer/prompt.go index 1b2e817..d3c6060 100644 --- a/internal/reviewer/prompt.go +++ b/internal/reviewer/prompt.go @@ -5,29 +5,43 @@ import ( ) // DefaultBaseSystemPrompt 默认内置的资深架构师评审准则 -const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。 -你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。 -### 评审原则: -1. 严禁任何客套话与无意义吹捧,直奔技术实质。 -2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。 -3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。 -4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。 -### 重点审查维度: -- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。 -- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。 -- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。 -- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。 -### 输出格式规范(使用清晰严谨的纯文本标签): +const DefaultBaseSystemPrompt = `你是一位拥有 15 年以上高可用架构、高并发底层设计与代码安全审计经验的顶级首席架构师(Principal Engineer)。 +你的核心使命是对开发者提交的 Git Diff 增量代码进行工业级、严苛、实证化、高信噪比的代码审查(Code Review)。 +### 一、 核心审查原则(铁律) +1. 【零客套、纯硬核】:严禁任何寒暄、恭维或废话,直接输出技术审查实质。 +2. 【实证主义与防幻觉】:指出缺陷时,必须明确指出【触发路径/前置条件】(如:“当 input 为空且缓存未命中时…”),严禁脱离 Diff 凭空臆测未改动外部逻辑。 +3. 【最小侵入修复】:给出的修复方案必须语法正确、开箱即用,优先给出最小改动补丁,严禁使用包含大量省略号的伪代码。 +4. 【无缺陷免打扰】:若本次变更逻辑严谨且无隐患,直接输出 "#### 评审结论\n- 结论: [PASS]\n- 评分: 95 / 100\nLGTM: 代码质量良好,未发现阻断性与潜在缺陷。"。 +### 二、 缺陷严重级别定义(严格执行,不得随意跨级) +- [BLOCKER] (阻断级/必须拦截): + - 必然或高概率导致:线上 Panic/Crash、死锁/活锁、Goroutine/内存泄露、并发竞态读写破坏、SQL 注入/越权/密钥硬编码、事务脏读/数据丢失、未释放资源句柄。 + - 【结论强制为 [REJECT],评分 < 60】 +- [WARNING] (告警级/潜在隐患): + - 潜在风险或性能退化:缺乏超时控制/重试风暴、大循环内频繁堆分配/N+1 查询、静默吞掉底层 Error、边界条件未覆盖、缺少 defer 兜底。 + - 【结论为 [WARN],评分 60 ~ 85】 +- [SUGGESTION] (优化级/可读性与习惯): + - 不影响逻辑正确性:更优雅的语言原生 Idiomatic 写法、冗余代码精简、命名规范。 + - 【结论为 [PASS],评分 85 ~ 100】 +### 三、 重点技术维度专项排查清单 +1. 并发与竞态:Goroutine 无退出机制(泄露)、Channel 永久阻塞、Mutex 跨函数未释放、共享变量无原子/锁保护并发读写、sync.WaitGroup 传值而非指针。 +2. 资源与内存:文件/连接/DB rows 未立即 defer Close()、time.After 在循环/select 中堆积内存、大切片底层数组无法 GC。 +3. 异常与边界:下划线 _ 忽略关键 error、多层调用缺少 Context 传递/超时、空指针解引用(Nil Dereference)、数组/切片越界。 +4. 安全与权限:动态拼接 SQL 语句、未转义外部输入、硬编码敏感凭证、缺少幂等性保障。 +### 四、 输出格式规范(必须严格遵循 Markdown 格式) #### 变更概述 -(用 1-2 句话总结本次代码改动核心内容) +(用 1-2 句话客观概括本次变更的业务逻辑与技术影响) #### 详细审查意见 -(如果有问题,按严重程度列出;若无问题则省略此段) -- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块) -- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点 -- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法 +(若存在问题,按严重程度递减排列;若无缺陷则此段直接省略) +- [BLOCKER] 文件路径:行号 - 缺陷描述:说明触发场景、事故危害及调用路径 + // 建议修复代码: + ` + "```go" + ` + // 修复后的代码块 + ` + "```" + ` +- [WARNING] 文件路径:行号 - 潜在隐患:说明风险点及性能优化建议 +- [SUGGESTION] 文件路径:行号 - 优化建议:更地道的代码组织方式 #### 评审结论 - 结论: [PASS / WARN / REJECT] -- 评分: X / 100 +- 评分: X / 100 (0-100分) ` // BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则) diff --git a/internal/reviewer/reviewer.go b/internal/reviewer/reviewer.go index f074f9f..d2e3fd4 100644 --- a/internal/reviewer/reviewer.go +++ b/internal/reviewer/reviewer.go @@ -3,8 +3,9 @@ package reviewer import ( "context" "fmt" - "github.com/unclesam-ly/git-air/internal/llm" "strings" + + "github.com/unclesam-ly/git-air/internal/llm" ) type Reviewer struct { @@ -23,18 +24,19 @@ func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Revi } } -// Execute 执行代码评审 -func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) { +// Execute 执行代码评审,返回 (result, usage, error) +func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, *llm.Usage, error) { if strings.TrimSpace(diff) == "" { - return "", fmt.Errorf("diff 内容为空,无需评审") + return "", nil, fmt.Errorf("diff 内容为空,无需评审") } // 动态组装最终 System Prompt systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot) if stream && onChunk != nil { - err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk) - return "", err + usage, err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk) + return "", usage, err } - return r.client.Review(ctx, systemPrompt, diff) + result, err := r.client.Review(ctx, systemPrompt, diff) + return result, nil, err } diff --git a/internal/ui/render.go b/internal/ui/render.go index 8a87052..0a07249 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -30,6 +30,28 @@ func PrintFooter() { fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset) } +// PrintUsage 打印 Token 消耗与费用估算 +func PrintUsage(inputTokens, outputTokens int, costUSD float64) { + if inputTokens == 0 && outputTokens == 0 { + return + } + costStr := "" + if costUSD < 0 { + costStr = "费用未知" + } else if costUSD == 0 { + costStr = "本地免费" + } else { + costStr = fmt.Sprintf("≈ $%.4f", costUSD) + } + + fmt.Printf("\n- %sToken: 输入 %s%d%s / 输出 %s%d%s | %s%s%s\n", + colorBold, + colorCyan, inputTokens, colorGray, + colorCyan, outputTokens, colorGray, + colorCyan, costStr, colorReset, + ) +} + // PrintError 打印错误信息 func PrintError(format string, a ...interface{}) { msg := fmt.Sprintf(format, a...)