5 Commits
Author SHA1 Message Date
unclesam 26b0342934 feat: 添加 Token 费用估算与自定义忽略规则
- 在 LLM 客户端中捕获流式响应的 Token 使用量
- 内置多模型官方定价表,支持自定义输入/输出单价覆盖
- 新增 `--price-input` 和 `--price-output` 配置项
- 支持通过 `.airignore` 文件自定义排除评审文件
- 更新中英日文文档,补充费用估算与忽略规则说明
2026-08-21 15:00:21 +08:00
unclesam 02e2e4edd3 fix(module): 更新 go.mod 与所有包导入路径为 github.com/unclesam-ly/git-air 2026-08-18 23:12:16 +08:00
unclesam 9de40815b0 fix(ui): 忽略 PrintError 的返回值 2026-08-18 11:30:49 +08:00
unclesam 16fd83412d fix(hook): pre-commit hook explicitly passes --staged flag 2026-08-18 11:25:48 +08:00
unclesam 1493b0a392 fix(hook): 支持 --cached/--staged 参数并优化 pre-commit 脚本 2026-08-18 11:21:22 +08:00
15 changed files with 679 additions and 78 deletions

No files matched your search

+64 -4
View File
@@ -45,14 +45,44 @@
它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺: 它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺:
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。 - ⚡ **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、防死循环。 - 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。 - 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
- 📊 **Token 消耗与费用实时估算**:实时捕获并统计输入/输出 Token 消耗,内置 2026 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 - 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 - 🪝 **一键 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 一键安装(推荐) ### 方式 1:通过 Go 一键安装(推荐)
@@ -112,9 +142,12 @@ git air config set --provider ollama --model qwen2.5-coder
# 12. OpenAI 官方 (GPT-4o / o3-mini) # 12. OpenAI 官方 (GPT-4o / o3-mini)
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-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 ```bash
git air config get git air config get
``` ```
@@ -148,7 +181,9 @@ git air --provider deepseek --model deepseek-chat
```bash ```bash
git air hook install 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 ```bash
@@ -160,7 +195,7 @@ git air hook uninstall
## ⚙️ 配置文件说明 ## ⚙️ 配置文件说明
`git-air` 支持多层配置覆盖,优先级从高到低为: `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`) 2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`) 3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
4. **全局用户配置**(`~/.git-air/config.yaml`) 4. **全局用户配置**(`~/.git-air/config.yaml`)
@@ -181,6 +216,10 @@ model: "gemini-3.7-flash"
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则) # 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
# custom_prompt: "" # 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! 本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
+78 -4
View File
@@ -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`): 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. - ⚡ **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. - 🛡️ **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. - 🎯 **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. - 📋 **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. - 🪝 **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 ## 📦 Installation
### Option 1: Via Go Install (Recommended) ### 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) # 12. OpenAI Official (GPT-4o / o3-mini)
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-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 ```bash
git air config get git air config get
``` ```
@@ -147,7 +180,9 @@ Install the hook inside any Git repository:
```bash ```bash
git air hook install 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: To uninstall:
```bash ```bash
@@ -159,11 +194,29 @@ git air hook uninstall
## ⚙️ Configuration Hierarchy ## ⚙️ Configuration Hierarchy
`git-air` searches for configurations in the following priority order: `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`) 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) 3. **Project Config** (`./config.yaml` or `./.git-air.yaml` in repo root)
4. **Global User Config** (`~/.git-air/config.yaml`) 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`) ## 📋 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 ## 📄 License
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome! This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
+77 -4
View File
@@ -44,14 +44,44 @@ GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air`
チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします: チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします:
- ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。 - ⚡ **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消費を抑え、思考ループを防ぎます。 - 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。
- 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。 - 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。
- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。
- 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。 - 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。
- 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録。危険なコードのコミットを未然にブロックします。 - 🪝 **ワンクリックで 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(推奨) ### 方法 1: Go Install(推奨)
@@ -111,9 +141,12 @@ git air config set --provider ollama --model qwen2.5-coder
# 12. OpenAI 公式 (GPT-4o / o3-mini) # 12. OpenAI 公式 (GPT-4o / o3-mini)
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-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 ```bash
git air config get git air config get
``` ```
@@ -147,7 +180,9 @@ Git リポジトリ直下で以下を実行:
```bash ```bash
git air hook install 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 ```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`) 2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`) 3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`)
4. **グローバル設定** (`~/.git-air/config.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`) ## 📋 チーム規約のカスタマイズ(`.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 も大歓迎です! 本プロジェクトは [MIT ライセンス](LICENSE) のもとで公開されています。Issue や Pull Request も大歓迎です!
+26 -12
View File
@@ -2,21 +2,23 @@ package cmd
import ( import (
"fmt" "fmt"
"git-air/internal/ui"
"os" "os"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/ui"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// AppConfig 全局配置结构体 // AppConfig 全局配置结构体
type AppConfig struct { type AppConfig struct {
Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
APIKey string `yaml:"api_key"` // API Key APIKey string `yaml:"api_key"` // API Key
BaseURL string `yaml:"base_url"` // 自定义请求地址 BaseURL string `yaml:"base_url"` // 自定义请求地址
Model string `yaml:"model"` // 模型名称 Model string `yaml:"model"` // 模型名称
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt 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) { func getConfigPath() (string, error) {
@@ -43,12 +45,11 @@ func LoadConfig() (*AppConfig, error) {
// 1. 首先加载全局配置 ~/.git-air/config.yaml // 1. 首先加载全局配置 ~/.git-air/config.yaml
configPath, err := getConfigPath() configPath, err := getConfigPath()
if err == nil { if err != nil {
if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 { // 隐式跳过时,可考虑记录日志或在 verbose 模式下输出
if err := yaml.Unmarshal(data, cfg); err != nil { } else if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
// 全局配置错误可输出警告或直接返回错误 if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析全局配置文件 [%s] 失败: %w", configPath, err) return nil, fmt.Errorf("解析全局配置文件 [%s] 失败: %w", configPath, err)
}
} }
} }
@@ -127,6 +128,12 @@ var configSetCmd = &cobra.Command{
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
cfg.CustomPrompt = 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 { if err := SaveConfig(cfg); err != nil {
ui.PrintError("保存配置失败: %v", err) ui.PrintError("保存配置失败: %v", err)
return return
@@ -161,6 +168,11 @@ var configGetCmd = &cobra.Command{
hasPrompt = "是 (已自定义)" hasPrompt = "是 (已自定义)"
} }
fmt.Printf("CustomPrompt: %s\n", 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")
}
}, },
} }
@@ -170,6 +182,8 @@ func init() {
configSetCmd.Flags().StringP("model", "m", "", "模型名称") configSetCmd.Flags().StringP("model", "m", "", "模型名称")
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址") configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt") 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(configSetCmd)
configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configGetCmd)
+3 -4
View File
@@ -1,19 +1,18 @@
package cmd package cmd
import ( import (
"git-air/internal/git"
"git-air/internal/ui"
"os" "os"
"path/filepath" "path/filepath"
"github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/ui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const preCommitScript = `#!/bin/sh const preCommitScript = `#!/bin/sh
# git-air pre-commit hook # git-air pre-commit hook
echo "[git-air] 正在执行 Commit 前代码自动化评审..." echo "[git-air] 正在执行 Commit 前代码自动化评审..."
git-air --cached git-air --staged
# 如果评审发现阻断性问题,可通过退出码阻断 commit
` `
var hookCmd = &cobra.Command{ var hookCmd = &cobra.Command{
+51 -10
View File
@@ -2,13 +2,14 @@ package cmd
import ( import (
"context" "context"
"git-air/internal/git"
"git-air/internal/llm"
"git-air/internal/reviewer"
"git-air/internal/ui"
"os" "os"
"strings"
"github.com/spf13/cobra" "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"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -21,6 +22,12 @@ var rootCmd = &cobra.Command{
ctx := cmd.Context() ctx := cmd.Context()
// 1. 检查并获取 Diff // 1. 检查并获取 Diff
if flagCached, _ := cmd.Flags().GetBool("cached"); flagCached {
args = append([]string{"--cached"}, args...)
} else if flagStaged, _ := cmd.Flags().GetBool("staged"); flagStaged {
args = append([]string{"--cached"}, args...)
}
diff, err := git.GetDiff(ctx, args...) diff, err := git.GetDiff(ctx, args...)
if err != nil { if err != nil {
ui.PrintError("%v", err) ui.PrintError("%v", err)
@@ -47,10 +54,12 @@ var rootCmd = &cobra.Command{
// 3. 初始化大模型客户端 // 3. 初始化大模型客户端
llmClient, err := llm.NewClient(llm.Config{ llmClient, err := llm.NewClient(llm.Config{
Provider: cfg.Provider, Provider: cfg.Provider,
APIKey: cfg.APIKey, APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL, BaseURL: cfg.BaseURL,
Model: cfg.Model, Model: cfg.Model,
PriceInput: cfg.PriceInput,
PriceOutput: cfg.PriceOutput,
}) })
if err != nil { if err != nil {
ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err) ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
@@ -70,15 +79,43 @@ var rootCmd = &cobra.Command{
// 6. 打印开场横幅并开始流式评审 // 6. 打印开场横幅并开始流式评审
ui.PrintBanner(cfg.Provider, cfg.Model) ui.PrintBanner(cfg.Provider, cfg.Model)
printer := ui.NewStreamPrinter() 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) printer.PrintChunk(chunk)
}) })
if err != nil { if err != nil {
ui.PrintError("\n评审过程中发生错误: %v", err) ui.PrintError("\n评审过程中发生错误: %v", err)
return os.Exit(1)
}
// 7. 打印 Token 消耗统计
if usage != nil {
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
} }
ui.PrintFooter() 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)
}
}
}, },
} }
@@ -99,4 +136,8 @@ func init() {
rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称") rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)") rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)")
rootCmd.Flags().String("prompt", "", "临时指定 System Prompt") 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 也不以非零退出码阻断")
} }
+7
View File
@@ -31,3 +31,10 @@ model: "gemini-3.7-flash"
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) # 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
custom_prompt: "" custom_prompt: ""
# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens)
# 默认情况下 git-air 内置了各主流模型官方最新价格表;
# 大模型厂商价格可能动态调整,请以实际价格为准
# 若你的中转平台价格有变或使用私有计费,可在此自定义覆盖:
# price_input: 0.75 # 每 1M 输入 Token 美元单价
# price_output: 3.75 # 每 1M 输出 Token 美元单价
+1 -1
View File
@@ -1,4 +1,4 @@
module git-air module github.com/unclesam-ly/git-air
go 1.26.6 go 1.26.6
+5 -2
View File
@@ -43,6 +43,9 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", ErrNotGitRepo return "", ErrNotGitRepo
} }
repoRoot, _ := GetRepoRoot(ctx)
customIgnores := LoadAirIgnore(repoRoot)
var cmdArgs []string var cmdArgs []string
if len(args) == 0 { if len(args) == 0 {
// 1. 默认先提取暂存区代码 (准备 commit 的代码) // 1. 默认先提取暂存区代码 (准备 commit 的代码)
@@ -51,7 +54,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(stagedDiff) != "" { if strings.TrimSpace(stagedDiff) != "" {
return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil
} }
// 2. 如果暂存区为空,回退提取工作区未暂存的修改 // 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{} cmdArgs = []string{}
@@ -64,7 +67,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
filteredDiff := FilterDiff(rawDiff) filteredDiff := FilterDiff(rawDiff, customIgnores...)
if strings.TrimSpace(filteredDiff) == "" { if strings.TrimSpace(filteredDiff) == "" {
return "", ErrEmptyDiff return "", ErrEmptyDiff
} }
+72 -14
View File
@@ -1,13 +1,14 @@
package git package git
import ( import (
"bufio"
"os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
) )
// 默认需要忽略的文件名与后缀列表 // 默认需要忽略的文件名与后缀列表
var ignoredPatterns = []string{ var defaultIgnoredPatterns = []string{
// 依赖与锁文件 // 依赖与锁文件
"go.sum", "go.sum",
"package-lock.json", "package-lock.json",
@@ -36,19 +37,45 @@ var ignoredPatterns = []string{
"*.pdf", "*.pdf",
} }
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略 // LoadAirIgnore 从仓库根目录加载 .airignore 文件中的自定义忽略规则
func isIgnoredFile(filePath string) bool { func LoadAirIgnore(repoRoot string) []string {
cleanPath := filepath.ToSlash(filePath) 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) baseName := filepath.Base(cleanPath)
for _, pattern := range ignoredPatterns { // 1. 先匹配用户自定义规则 (.airignore)
// 精确匹配文件名(如 go.sum) for _, pattern := range customPatterns {
if pattern == baseName { if matchPattern(pattern, cleanPath, baseName) {
return true return true
} }
}
// 通配符匹配(如 *.pb.go) // 2. 再匹配内置默认规则
if matched, _ := filepath.Match(pattern, baseName); matched { for _, pattern := range defaultIgnoredPatterns {
if matchPattern(pattern, cleanPath, baseName) {
return true return true
} }
} }
@@ -56,10 +83,41 @@ func isIgnoredFile(filePath string) bool {
return false 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 中无意义的锁文件和自动生成文件的区块 // 目录规则:如 "docs/" 或 "vendor/"
func FilterDiff(rawDiff string) string { 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) == "" { if strings.TrimSpace(rawDiff) == "" {
return "" return ""
} }
@@ -85,7 +143,7 @@ func FilterDiff(rawDiff string) string {
parts := strings.Fields(firstLine) parts := strings.Fields(firstLine)
if len(parts) >= 2 { if len(parts) >= 2 {
filePath := strings.TrimPrefix(parts[1], "b/") filePath := strings.TrimPrefix(parts[1], "b/")
if isIgnoredFile(filePath) { if isIgnoredFile(filePath, customPatterns) {
continue // 命中忽略规则,跳过这个文件的 diff continue // 命中忽略规则,跳过这个文件的 diff
} }
} }
+112
View File
@@ -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")
}
}
+150 -14
View File
@@ -12,16 +12,20 @@ import (
// Config 大模型统一配置 // Config 大模型统一配置
type Config struct { type Config struct {
Provider string // "gemini", "deepseek", "openai", "ollama", "custom" Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
APIKey string // 密钥 (Ollama 本地可填任意字符或留空) APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值) BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
Model string // 模型名称 (非必填,会自动设置默认主力模型) Model string // 模型名称 (非必填,会自动设置默认主力模型)
PriceInput float64 // 用户自定义每 1M 输入 Token 美元价格 (可选,覆盖内置价格表)
PriceOutput float64 // 用户自定义每 1M 输出 Token 美元价格 (可选,覆盖内置价格表)
} }
// Client 统一大模型客户端 // Client 统一大模型客户端
type Client struct { type Client struct {
client *openai.Client client *openai.Client
model string model string
priceInput float64
priceOutput float64
} }
// NewClient 初始化客户端 // NewClient 初始化客户端
@@ -227,8 +231,10 @@ func NewClient(cfg Config) (*Client, error) {
clientConfig.BaseURL = baseURL clientConfig.BaseURL = baseURL
return &Client{ return &Client{
client: openai.NewClientWithConfig(clientConfig), client: openai.NewClientWithConfig(clientConfig),
model: model, model: model,
priceInput: cfg.PriceInput,
priceOutput: cfg.PriceOutput,
}, nil }, nil
} }
@@ -254,8 +260,122 @@ func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string,
return resp.Choices[0].Message.Content, nil return resp.Choices[0].Message.Content, nil
} }
// ReviewStream 流式逐字返回评审结果(打字机效果) // Usage 记录一次调用的 Token 消耗
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error { 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{ stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: c.model, Model: c.model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
@@ -264,18 +384,27 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
}, },
Temperature: 0.2, Temperature: 0.2,
Stream: true, Stream: true,
StreamOptions: &openai.StreamOptions{
IncludeUsage: true,
},
}) })
if err != nil { if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err) return nil, fmt.Errorf("创建流式请求失败: %w", err)
} }
if stream != nil { if stream != nil {
defer stream.Close() defer stream.Close()
} }
usage := &Usage{
Model: c.model,
CustomPriceInput: c.priceInput,
CustomPriceOutput: c.priceOutput,
}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@@ -284,8 +413,15 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
break break
} }
if err != nil { 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 { if len(response.Choices) > 0 {
chunk := response.Choices[0].Delta.Content chunk := response.Choices[0].Delta.Content
if chunk != "" { if chunk != "" {
@@ -294,5 +430,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
} }
} }
return nil return usage, nil
} }
+9 -7
View File
@@ -3,8 +3,9 @@ package reviewer
import ( import (
"context" "context"
"fmt" "fmt"
"git-air/internal/llm"
"strings" "strings"
"github.com/unclesam-ly/git-air/internal/llm"
) )
type Reviewer struct { type Reviewer struct {
@@ -23,18 +24,19 @@ func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Revi
} }
} }
// Execute 执行代码评审 // Execute 执行代码评审,返回 (result, usage, error)
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) { func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, *llm.Usage, error) {
if strings.TrimSpace(diff) == "" { if strings.TrimSpace(diff) == "" {
return "", fmt.Errorf("diff 内容为空,无需评审") return "", nil, fmt.Errorf("diff 内容为空,无需评审")
} }
// 动态组装最终 System Prompt // 动态组装最终 System Prompt
systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot) systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
if stream && onChunk != nil { if stream && onChunk != nil {
err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk) usage, err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
return "", err return "", usage, err
} }
return r.client.Review(ctx, systemPrompt, diff) result, err := r.client.Review(ctx, systemPrompt, diff)
return result, nil, err
} }
+23 -1
View File
@@ -30,10 +30,32 @@ func PrintFooter() {
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset) 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 打印错误信息 // PrintError 打印错误信息
func PrintError(format string, a ...interface{}) { func PrintError(format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...) msg := fmt.Sprintf(format, a...)
fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg) _, _ = fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg)
} }
// PrintSuccess 打印成功信息 // PrintSuccess 打印成功信息
+1 -1
View File
@@ -1,6 +1,6 @@
package main package main
import "git-air/cmd" import "github.com/unclesam-ly/git-air/cmd"
func main() { func main() {
cmd.Execute() cmd.Execute()