From 4c9f7f3f5897db83c5433a09dd9db569c7271f42 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B6=82=E7=91=9C?= <861506831@qq.com>
Date: Thu, 16 Apr 2026 13:44:39 +0800
Subject: [PATCH] feat(commit): fix file mismatch, add streaming output and
unversioned file support (#12)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* ci: add GitHub Actions workflow for build and test
* feat(action): 添加两阶段提交消息生成器及相关功能
* chore: add PR-Agent workflow and auto-commit deep-dive doc
- Add .github/workflows/pr-agent.yml for automated PR code review
- Add .github/pr_agent.toml with review config (security, tests, zh)
- Add docs/auto-commit-deep-dive.md covering flow, design decisions,
known limitations, and market comparison with Copilot/JetBrains/Cursor
* feat(action): 添加两阶段提交消息生成器及相关功能
---------
Co-authored-by: Yuang Peng <116251109+genni613@users.noreply.github.com>
---
.github/pr_agent.toml | 13 ++
.github/workflows/pr-agent.yml | 26 +++
README.md | 158 +--------------
docs/auto-commit-deep-dive.md | 180 ++++++++++++++++++
.../codeplangui/action/CommitPromptBuilder.kt | 51 ++---
.../action/GenerateCommitMessageAction.kt | 51 ++++-
.../action/TwoStageCommitGenerator.kt | 114 ++++++++---
.../github/codeplangui/api/OkHttpSseClient.kt | 27 +++
8 files changed, 405 insertions(+), 215 deletions(-)
create mode 100644 .github/pr_agent.toml
create mode 100644 .github/workflows/pr-agent.yml
create mode 100644 docs/auto-commit-deep-dive.md
diff --git a/.github/pr_agent.toml b/.github/pr_agent.toml
new file mode 100644
index 0000000..e44305f
--- /dev/null
+++ b/.github/pr_agent.toml
@@ -0,0 +1,13 @@
+[pr_reviewer]
+require_score_review = false
+require_tests_review = true
+require_security_review = true
+require_focused_review = true
+num_code_suggestions = 4
+
+[pr_description]
+publish_labels = false
+use_bullet_points = true
+
+[config]
+language = "zh"
diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml
new file mode 100644
index 0000000..fe1316e
--- /dev/null
+++ b/.github/workflows/pr-agent.yml
@@ -0,0 +1,26 @@
+name: PR-Agent
+
+on:
+ pull_request:
+ types: [opened, reopened, ready_for_review]
+ pull_request_review_comment:
+ types: [created]
+ issue_comment:
+ types: [created]
+
+permissions:
+ issues: write
+ pull-requests: write
+ contents: read
+
+jobs:
+ pr_agent:
+ if: ${{ github.event.sender.type != 'Bot' }}
+ runs-on: ubuntu-latest
+ name: Run PR-Agent
+ steps:
+ - name: PR-Agent
+ uses: Codium-ai/pr-agent@main
+ env:
+ OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 82df127..4e889bd 100644
--- a/README.md
+++ b/README.md
@@ -83,86 +83,9 @@ build/distributions/CodePlanGUI-0.1.0.zip
JAVA_HOME=/path/to/jdk17 ./gradlew buildWebview runIde
```
-### Roadmap Principles
-
-- Build trust in the current surface before expanding it.
-- Make state, errors, and permissions explicit before adding more automation.
-- Ship IDE-native actions only when they are testable, reversible, and easy to diagnose.
-- Keep the README aligned with real behavior; future capabilities stay clearly marked as future.
-
### Roadmap
-**Phase 1 — Trustworthy Chat Foundation**
-
-User-visible outcomes:
-- Stable Chat, Ask AI, context injection, commit message generation, and theme sync
-- Clear status and error messages for provider, API key, network, and context problems
-- Honest capability boundaries: the assistant must not pretend it executed commands or inspected local files
-
-Engineering foundations:
-- Unified bridge lifecycle for `ready`, `status`, `theme`, and `context` events
-- Structured error handling across plugin, webview, and provider calls
-- Regression coverage for chat, selection flow, settings persistence, and commit generation
-
-Acceptance:
-- No silent failure in the main chat flows
-- Errors distinguish configuration issues from runtime failures
-- UI state remains correct after reload, theme switch, and provider change
-
-**Phase 2 — IDE-Native Productivity**
-
-User-visible outcomes:
-- IDE-native inline completion with automatic ghost-text suggestions
-- Conversation history with restore and search
-- One-click code insertion from chat with predictable undo behavior
-- Better commit message generation based on the actual selected commit scope
-- Faster provider switching from the IDE surface
-
-Engineering foundations:
-- Editor event listening, debounce/cancel flow, and inlay rendering for low-latency completion
-- Local session persistence and retrieval model
-- Shared context-summary pipeline for file, selection, and commit-change scopes
-- Reusable action entry points shared by tool window, editor actions, and commit UI
-
-Acceptance:
-- Inline suggestions appear automatically, can be accepted with a single action, and cancel cleanly on cursor/context change
-- Users can restore previous conversations reliably
-- Commit generation reflects the selected change scope instead of a generic diff
-- Code insertion and provider switching behave consistently across IDE restarts
-
-**Phase 3 — Safe Action Surfaces**
-
-User-visible outcomes:
-- Controlled command execution from the chat surface
-- Permission prompts and clear execution result cards
-- Auditable summaries of what ran, why it ran, and what happened
-
-Engineering foundations:
-- Tool invocation protocol between webview, plugin host, and execution runtime
-- Permission model, timeout handling, and structured result payloads
-- Runtime classification for execution, network, permission, and sandbox failures
-
-Acceptance:
-- No command runs without explicit authorization
-- Every execution shows source, status, and output summary
-- Tool failures are reported as structured errors instead of vague assistant prose
-
-**Phase 4 — Agent and MCP Expansion**
-
-User-visible outcomes:
-- MCP server integration
-- Agent mode and slash commands for multi-step workflows
-- Token usage and cost visibility
-
-Engineering foundations:
-- Lifecycle management for MCP servers and agent sessions
-- Structured event and state model for long-running tasks
-- Health checks, degraded-mode reporting, and recovery hooks
-
-Acceptance:
-- MCP and agent failures are diagnosable without reading raw logs
-- Long-running actions expose explicit status transitions
-- New automation surfaces reuse the permission and event model established earlier
+See [docs/roadmap.md](docs/roadmap.md) for the full phase plan, engineering tasks, and acceptance criteria.
**Explicitly not available today:**
- Command execution from chat
@@ -255,86 +178,9 @@ build/distributions/CodePlanGUI-0.1.0.zip
JAVA_HOME=/path/to/jdk17 ./gradlew buildWebview runIde
```
-### 路线原则
-
-- 先把现有能力做可信,再扩展能力边界。
-- 先把状态、错误和权限说清楚,再引入更强的自动化。
-- 只有可测试、可撤销、可诊断的 IDE 动作才进入正式功能面。
-- README 只写真实能力和有明确验收标准的规划,不提前夸大。
-
### 迭代计划
-**Phase 1 — 可信的 Chat 基础层**
-
-用户可见结果:
-- Chat、Ask AI、上下文注入、Commit Message 生成和主题同步稳定可用
-- Provider、API Key、网络、上下文异常都有明确状态和错误提示
-- 助手不会再伪装自己执行过命令或读取过本地文件
-
-工程支撑:
-- 统一 `ready`、`status`、`theme`、`context` 的 bridge 生命周期
-- 插件、webview、Provider 请求链路的错误分类和兜底
-- 为聊天、选中代码、设置持久化、commit 生成补齐回归测试
-
-验收标准:
-- 主聊天链路不再出现静默失败
-- 错误能区分配置问题和运行时问题
-- Reload、主题切换、Provider 切换后 UI 状态仍与真实状态一致
-
-**Phase 2 — IDE 原生提效层**
-
-用户可见结果:
-- IDE 原生的自动灰字补全
-- 会话历史与恢复、搜索
-- 一键把 Chat 中的代码插入编辑器,并且撤销行为可预期
-- Commit Message 生成基于实际选中的提交范围,而不是泛化 diff
-- 更快的 Provider 切换入口
-
-工程支撑:
-- 面向编辑器输入监听、去抖/取消、Inlay 渲染的低延迟补全链路
-- 本地会话持久化与恢复模型
-- 面向文件、选区、提交变更范围的统一上下文摘要能力
-- Tool Window、编辑器动作、提交面板复用同一套动作入口
-
-验收标准:
-- 灰字建议自动出现,可单步接受,并在光标或上下文变化时正确取消
-- 用户能稳定恢复历史会话
-- Commit Message 与实际勾选变更范围一致
-- 代码插入和 Provider 切换在 IDE 重启后仍保持一致行为
-
-**Phase 3 — 安全执行能力层**
-
-用户可见结果:
-- 受控的命令执行入口
-- 清晰的权限确认和执行结果卡片
-- 用户能看到“执行了什么、为什么执行、结果如何”的摘要
-
-工程支撑:
-- webview、插件宿主、执行运行时之间的工具调用协议
-- 权限模型、超时控制、结构化结果回传
-- 对执行失败、网络失败、权限失败、沙箱失败做运行时分类
-
-验收标准:
-- 未经明确授权不得执行命令
-- 每次执行都展示来源、状态和输出摘要
-- 工具失败以结构化错误呈现,而不是模糊的 AI 文本描述
-
-**Phase 4 — Agent 与 MCP 扩展层**
-
-用户可见结果:
-- MCP Server 集成
-- Agent 模式与 slash command 多步骤工作流
-- token 用量与成本可见性
-
-工程支撑:
-- MCP Server 与 Agent Session 生命周期管理
-- 长任务的结构化事件流和状态模型
-- 健康检查、降级模式报告和恢复钩子
-
-验收标准:
-- MCP 和 Agent 失败无需读原始日志也能定位
-- 长任务具备明确的状态流转
-- 新自动化能力复用前面已经建立的权限和事件模型
+完整分阶段计划见 [docs/roadmap.md](docs/roadmap.md)。
**当前明确不具备:**
- 在 Chat 中直接执行命令
diff --git a/docs/auto-commit-deep-dive.md b/docs/auto-commit-deep-dive.md
new file mode 100644
index 0000000..fc3f449
--- /dev/null
+++ b/docs/auto-commit-deep-dive.md
@@ -0,0 +1,180 @@
+# Auto Commit Message Generation — 深度解析
+
+> 适用版本:CodePlanGUI(当前 fix/commit-message-generation 分支)
+
+---
+
+## 一、整体流程图
+
+```
+用户点击「生成 Commit Message」
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ 收集勾选文件 │
+│ 1. 反射读取 getIncludedChanges() │ ← 已追踪的修改/删除文件
+│ 2. 反射读取 getIncludedUnversionedFiles()│ ← 未追踪的新文件(直接读磁盘)
+│ 3. 兜底:VcsDataKeys.CHANGES │
+│ ↓ 全为空 → 提示用户勾选文件,退出 │
+└─────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ DiffAnalyzer.analyze() │
+│ · 统计每个文件的增删行数 │
+│ · 计算 totalDiffLines │
+│ · totalDiffLines < limit → FULL 模式 │
+│ · totalDiffLines ≥ limit → STATS 模式 │
+└─────────────────────────────────────────┘
+ │
+ ┌────┴────┐
+ ▼ ▼
+STATS 模式 FULL 模式
+ │ │
+ │ ▼
+ │ DiffAnalyzer.filterFiles()
+ │ · 50% 规则:代码行 > 50% 时过滤文档文件
+ │ · 过滤 lock 文件、图片、.idea/、.code/
+ │ · 按 commitMaxFiles 截断
+ │ │
+ │ filteredFiles.size ≤ 3?
+ │ │
+ │ ┌────┴────┐
+ │ ▼ ▼
+ │ 直接路径 两阶段路径
+ │ (1次调用) (N+1次调用)
+ │ │ │
+ │ │ ▼
+ │ │ Stage 1(并发)
+ │ │ 每个文件独立调用 AI
+ │ │ · 输入:文件路径 + 实际 diff 内容(含截断)
+ │ │ · 输出:一句话摘要
+ │ │ · maxTokens: 100
+ │ │ │
+ │ └────┬────┘
+ │ ▼
+ │ Stage 2 / 直接路径
+ │ · 输入:所有摘要 或 所有文件 diff
+ │ · 输出:Conventional Commits 格式 commit message
+ │ · maxTokens: 500
+ │ · 流式输出 → 实时写入 commit 输入框
+ │ │
+ └─────────┘
+ │
+ ▼
+ stripThinkContent() ← 过滤 ...(DeepSeek 等模型)
+ │
+ ▼
+ 最终写入 commit 输入框
+```
+
+---
+
+## 二、关键设计决策
+
+### 2.1 为什么要两阶段?
+
+直接把所有文件 diff 打包发给 AI 有两个问题:
+
+1. **Token 限制**:10 个文件 × 平均 200 行 = 大量 token,容易超出模型上下文
+2. **质量问题**:一次喂太多信息,AI 容易漏掉细节或混淆不同文件的改动
+
+两阶段的思路是**分而治之**:先让 AI 逐个理解每个文件,再综合出一条 commit。
+
+### 2.2 为什么 ≤3 个文件走直接路径?
+
+两阶段的开销是固定的:
+- 每个文件一次 API 调用(网络 RTT ≈ 1-3s)
+- 再加一次 Stage 2
+
+对于 1-3 个文件,N+1 次调用 > 1 次调用,且内容量完全在模型处理范围内,两阶段反而更慢、没有必要。
+
+### 2.3 STATS 模式的取舍
+
+当变更量超过阈值(默认行数由设置决定),退化为只传文件名+行数,一次调用生成。
+
+**优点**:快,不超 token 限制
+**缺点**:AI 靠文件名和行数猜,准确性下降
+
+这是速度和质量的有意权衡。
+
+### 2.4 为什么 Stage 1 不流式,Stage 2 才流式?
+
+Stage 1 的摘要是中间产物,用户不需要看到。流式对用户体验有意义的前提是"这个输出是最终结果"。Stage 2 的输出直接写入输入框,流式能带来"实时打字"的感知,消除等待焦虑。
+
+---
+
+## 三、核心代码路径
+
+| 文件 | 职责 |
+|------|------|
+| `GenerateCommitMessageAction.kt` | 入口:收集文件、调度任务、写入输入框 |
+| `TwoStageCommitGenerator.kt` | 路由逻辑:STATS/直接/两阶段,协调 API 调用 |
+| `DiffAnalyzer.kt` | 分析变更量,决定压缩级别,过滤文件 |
+| `CommitPromptBuilder.kt` | 构造各阶段的 Prompt |
+| `OkHttpSseClient.kt` | HTTP 客户端:同步调用、流式调用、重试逻辑 |
+
+---
+
+## 四、已知限制
+
+1. **STATS 模式质量差**:文件名推测不可靠,尤其是通用名称(`utils.ts`、`index.kt`)
+2. **50% 规则过于粗糙**:代码+文档混合提交时,文档文件可能被静默过滤
+3. **反射脆弱性**:`getIncludedChanges` / `getIncludedUnversionedFiles` 依赖 IntelliJ 内部 API,版本升级可能失效
+4. **流式 think 过滤边界处理简单**:若 `` 跨 token 切割,当前实现可能误显示部分标签
+5. **单次 Stage 1 超时达 186 秒**(60s × 3 次重试),并发下单个文件失败不影响整体,但体验仍差
+
+---
+
+## 五、与市面上其他方案的对比
+
+### 5.1 横向对比表
+
+| 维度 | CodePlanGUI | GitHub Copilot | JetBrains AI Assistant | aicommit2 (CLI) | Cursor |
+|------|-------------|----------------|------------------------|-----------------|--------|
+| **集成方式** | IntelliJ 插件 | IDE 插件(VS Code/JetBrains) | JetBrains 原生 | 命令行 | 独立 IDE |
+| **输入来源** | 勾选文件内容 | `git diff --staged` | `git diff --staged` | `git diff --staged` | 编辑器上下文 |
+| **是否读文件内容** | ✅ 读实际 diff | ✅ | ✅ | ✅ | ✅ |
+| **多文件处理** | 两阶段分治 | 直接一次打包 | 直接一次打包 | 直接一次打包 | 直接一次打包 |
+| **流式输出** | ✅ | ✅ | ✅ | ❌ | ✅ |
+| **模型可配置** | ✅ 任意 OpenAI 兼容 | ❌ 仅 Copilot 模型 | ❌ 仅 JetBrains AI | ✅(需配置 Key) | ❌ 仅 Cursor 模型 |
+| **格式规范** | Conventional Commits | 不强制 | 不强制 | 可选 Conventional | 不强制 |
+| **未追踪文件支持** | ✅ | ✅ | ✅ | ✅(需 git add) | ✅ |
+| **离线/私有模型** | ✅(配置 endpoint) | ❌ | ❌ | ✅(Ollama) | ❌ |
+| **价格** | 按 API 用量 | 订阅制 $10/月 | 订阅制 $8/月 | 免费(自带 Key) | 订阅制 $20/月 |
+
+### 5.2 各方案的核心差异
+
+**GitHub Copilot**
+优势在于与 VS Code / JetBrains 深度集成,点击即用,无需配置。生成质量稳定。
+劣势:模型固定,不支持私有部署,对大型 diff 的处理是简单截断而非分治。
+
+**JetBrains AI Assistant**
+和 CodePlanGUI 运行在同一个宿主环境里,优势是能访问更多 IDE 上下文(光标位置、打开的文件等)。但模型不可换,且对 Conventional Commits 格式不强制。
+
+**aicommit2(CLI)**
+灵活性最高,支持多种模型(OpenAI、Anthropic、Gemini、Ollama)和多种 commit 格式。劣势是 CLI 工具,没有 IDE 集成,需要手动配置,不支持流式输出,体验不如 IDE 插件流畅。
+
+**Cursor**
+生成质量高,但 Cursor 本身是独立 IDE,如果已经在用 IntelliJ 系,切换成本高。
+
+### 5.3 CodePlanGUI 的差异化定位
+
+**相对优势:**
+- **模型自由**:可接入任意 OpenAI 兼容 endpoint(本地 Ollama、私有部署、国产大模型)
+- **两阶段分治**:大 diff 下比简单打包策略质量更高
+- **Conventional Commits 强制执行**:系统提示明确要求,格式一致性好
+
+**相对劣势:**
+- **两阶段对小提交反而慢**(已通过直接路径部分改善)
+- **反射获取文件列表的稳定性**:依赖 IntelliJ 内部 API,存在版本兼容风险
+- **STATS 模式降级明显**:超过阈值后质量骤降,不如 Copilot 的简单截断策略
+- **无法感知语义上下文**:只看 diff,不看当前 branch 名、PR 描述、issue 关联等
+
+### 5.4 一句话总结
+
+> 如果你在 IntelliJ 生态、需要接入私有/国产模型、重视 Conventional Commits 格式——CodePlanGUI 有明显优势。如果你在 VS Code 且已订阅 Copilot,直接用 Copilot 即可,无需额外配置。
+
+---
+
+*文档生成于 2026-04-15,基于 fix/commit-message-generation 分支代码。*
diff --git a/src/main/kotlin/com/github/codeplangui/action/CommitPromptBuilder.kt b/src/main/kotlin/com/github/codeplangui/action/CommitPromptBuilder.kt
index 7881a26..dbf702e 100644
--- a/src/main/kotlin/com/github/codeplangui/action/CommitPromptBuilder.kt
+++ b/src/main/kotlin/com/github/codeplangui/action/CommitPromptBuilder.kt
@@ -20,7 +20,7 @@ object CommitPromptBuilder {
fun stripThinkContent(content: String): String {
return content
.replace(Regex("[\\s\\S]*?", RegexOption.DOT_MATCHES_ALL), "")
- .replace(Regex("[\\s\\S]*?", RegexOption.DOT_MATCHES_ALL), "")
+ .replace(Regex("[\\s\\S]*$", RegexOption.DOT_MATCHES_ALL), "")
.trim()
}
@@ -75,15 +75,20 @@ Rules:
fun buildStatsUserMessage(
files: List,
+ categorySummary: String,
language: String
): String {
val langInstruction = if (language == "zh") "中文" else "English"
- val summary = files.joinToString("\n") { file ->
+ val fileList = files.joinToString("\n") { file ->
"${file.path}: (${file.additions} additions, ${file.deletions} deletions)"
}
return """
Below is a summary of ${files.size} changed files. Generate an appropriate commit message based on this summary.
-$summary
+
+Category overview: $categorySummary
+
+Files:
+$fileList
Generate the commit message in ${langInstruction}, following Conventional Commits format.
""".trimIndent()
@@ -120,6 +125,11 @@ Generate the commit message in ${langInstruction}, following Conventional Commit
return "File: $path\nChange type: $changeType\nChanged lines: $lines"
}
+ fun buildSingleFilePrompt(file: CommitPromptFile): String {
+ val diffContent = buildDiffPreview(listOf(file))
+ return "File: ${file.path}\nChange type: ${file.changeType}\n\n$diffContent"
+ }
+
fun buildSystemPrompt(language: String, format: String = "conventional"): String {
val langInstruction = if (language == "zh") "中文" else "English"
val formatInstruction = if (format == "freeform") {
@@ -189,31 +199,22 @@ $formatInstruction
private fun previewModifiedFile(before: String?, after: String?): String {
if (before == null || after == null) return "[modified content unavailable]\n"
- val beforeLines = before.lines()
- val afterLines = after.lines()
- val maxLines = maxOf(beforeLines.size, afterLines.size)
- val changed = mutableListOf()
+ val beforeSet = before.lines().toSet()
+ val afterSet = after.lines().toSet()
- for (index in 0 until maxLines) {
- if (changed.size >= MAX_CHANGED_LINES) break
- val beforeLine = beforeLines.getOrNull(index)
- val afterLine = afterLines.getOrNull(index)
- if (beforeLine == afterLine) continue
+ val removed = before.lines().filter { it.isNotBlank() && it !in afterSet }
+ val added = after.lines().filter { it.isNotBlank() && it !in beforeSet }
- if (!beforeLine.isNullOrEmpty()) {
- changed += "- $beforeLine"
- }
- if (!afterLine.isNullOrEmpty() && changed.size < MAX_CHANGED_LINES) {
- changed += "+ $afterLine"
- }
- }
+ if (removed.isEmpty() && added.isEmpty()) return "[content changed but no compact diff available]\n"
+
+ val changed = mutableListOf()
+ val maxRemoved = MAX_CHANGED_LINES / 2
+ removed.take(maxRemoved).forEach { changed += "- $it" }
+ added.take(MAX_CHANGED_LINES - changed.size).forEach { changed += "+ $it" }
+
+ val truncated = removed.size > maxRemoved || added.size > (MAX_CHANGED_LINES - removed.size.coerceAtMost(maxRemoved))
+ if (truncated) changed += "... [more changes omitted]"
- if (changed.isEmpty()) {
- return "[content changed but no compact diff available]\n"
- }
- if (maxLines > MAX_CHANGED_LINES) {
- changed += "... [more changes omitted]"
- }
return changed.joinToString("\n", postfix = "\n")
}
}
diff --git a/src/main/kotlin/com/github/codeplangui/action/GenerateCommitMessageAction.kt b/src/main/kotlin/com/github/codeplangui/action/GenerateCommitMessageAction.kt
index bad4e30..e4b9151 100644
--- a/src/main/kotlin/com/github/codeplangui/action/GenerateCommitMessageAction.kt
+++ b/src/main/kotlin/com/github/codeplangui/action/GenerateCommitMessageAction.kt
@@ -11,7 +11,6 @@ import com.intellij.openapi.progress.Task
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.vcs.VcsDataKeys
import com.intellij.openapi.vcs.changes.Change
-import com.intellij.openapi.vcs.changes.ChangeListManager
import com.intellij.openapi.vcs.changes.ChangesUtil
import kotlinx.coroutines.runBlocking
import java.lang.reflect.Method
@@ -86,9 +85,18 @@ class GenerateCommitMessageAction : AnAction() {
indicator: com.intellij.openapi.progress.ProgressIndicator
) {
val generator = TwoStageCommitGenerator(client, activeProvider, apiKey)
+ val accumulated = StringBuilder()
+
+ val onToken: (String) -> Unit = { token ->
+ accumulated.append(token)
+ val cleaned = CommitPromptBuilder.stripThinkContent(accumulated.toString())
+ ApplicationManager.getApplication().invokeLater {
+ applyCommitMessage(e, project, cleaned)
+ }
+ }
val result = runBlocking {
- generator.generate(files, settings, indicator)
+ generator.generate(files, settings, indicator, onToken)
}
ApplicationManager.getApplication().invokeLater {
@@ -98,6 +106,8 @@ class GenerateCommitMessageAction : AnAction() {
applyCommitMessage(e, project, cleaned.trim())
},
onFailure = { err ->
+ // Clear any partial content on failure
+ if (accumulated.isNotEmpty()) applyCommitMessage(e, project, "")
Messages.showErrorDialog(project, err.message ?: "API 调用失败", "生成失败")
}
)
@@ -145,9 +155,11 @@ class GenerateCommitMessageAction : AnAction() {
private fun buildSelectedFiles(e: AnActionEvent, project: com.intellij.openapi.project.Project): List {
val changes = getSelectedChanges(e, project)
- if (changes.isEmpty()) return emptyList()
+ val unversionedFiles = getIncludedUnversionedFiles(e)
- return changes.mapNotNull { change ->
+ if (changes.isEmpty() && unversionedFiles.isEmpty()) return emptyList()
+
+ val fromChanges = changes.mapNotNull { change ->
try {
CommitPromptFile(
path = ChangesUtil.getFilePath(change).path,
@@ -159,6 +171,22 @@ class GenerateCommitMessageAction : AnAction() {
null
}
}
+
+ val fromUnversioned = unversionedFiles.mapNotNull { filePath ->
+ try {
+ val file = java.io.File(filePath.path)
+ CommitPromptFile(
+ path = filePath.path,
+ changeType = "NEW",
+ beforeContent = null,
+ afterContent = if (file.isFile) file.readText() else null
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ return fromChanges + fromUnversioned
}
private fun getSelectedChanges(
@@ -176,7 +204,7 @@ class GenerateCommitMessageAction : AnAction() {
return directChanges.asList()
}
- return ChangeListManager.getInstance(project).allChanges
+ return emptyList()
}
private fun getIncludedChangesViaReflection(workflowHandler: Any): Collection? {
@@ -191,6 +219,19 @@ class GenerateCommitMessageAction : AnAction() {
}
}
+ private fun getIncludedUnversionedFiles(e: AnActionEvent): List {
+ val workflowHandler = e.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER) ?: return emptyList()
+ return try {
+ val getUiMethod: Method = workflowHandler.javaClass.getMethod("getUi")
+ val ui = getUiMethod.invoke(workflowHandler) ?: return emptyList()
+ val getMethod: Method = ui.javaClass.getMethod("getIncludedUnversionedFiles")
+ val result = getMethod.invoke(ui) as? Collection<*> ?: return emptyList()
+ result.filterIsInstance()
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
private fun readStagedDiff(projectDir: String): String {
return try {
ProcessBuilder("git", "diff", "--staged", "--no-color")
diff --git a/src/main/kotlin/com/github/codeplangui/action/TwoStageCommitGenerator.kt b/src/main/kotlin/com/github/codeplangui/action/TwoStageCommitGenerator.kt
index 7fa2602..11c0b5f 100644
--- a/src/main/kotlin/com/github/codeplangui/action/TwoStageCommitGenerator.kt
+++ b/src/main/kotlin/com/github/codeplangui/action/TwoStageCommitGenerator.kt
@@ -7,17 +7,26 @@ import com.github.codeplangui.settings.ProviderConfig
import com.github.codeplangui.settings.SettingsState
import com.intellij.openapi.progress.ProgressIndicator
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
+import java.util.concurrent.atomic.AtomicInteger
class TwoStageCommitGenerator(
private val client: OkHttpSseClient,
private val provider: ProviderConfig,
private val apiKey: String
) {
+ companion object {
+ // Files <= this threshold skip Stage 1 and go directly to Stage 2 in one API call
+ private const val DIRECT_THRESHOLD = 3
+ }
+
suspend fun generate(
files: List,
settings: SettingsState,
- indicator: ProgressIndicator
+ indicator: ProgressIndicator,
+ onToken: (String) -> Unit = {}
): Result = withContext(Dispatchers.IO) {
val analyzer = DiffAnalyzer()
val analysisResult = analyzer.analyze(files, settings)
@@ -26,34 +35,72 @@ class TwoStageCommitGenerator(
if (analysisResult.level == DiffAnalyzer.CompressionLevel.STATS) {
// STATS level: skip Stage 1, generate directly from stats
- return@withContext generateFromStats(analysisResult, settings, indicator)
+ return@withContext generateFromStats(analysisResult, settings, indicator, onToken)
}
- // FULL level: two-stage generation
- return@withContext generateTwoStage(analysisResult, settings, indicator)
+ // FULL level: use direct path for small commits, two-stage for larger ones
+ val filteredFiles = DiffAnalyzer().filterFiles(analysisResult, settings)
+ return@withContext if (filteredFiles.size <= DIRECT_THRESHOLD) {
+ generateDirect(filteredFiles, files, settings, indicator, onToken)
+ } else {
+ generateTwoStage(filteredFiles, files, settings, indicator, onToken)
+ }
}
- private suspend fun generateTwoStage(
- result: DiffAnalyzer.AnalysisResult,
+ private suspend fun generateDirect(
+ filteredFiles: List,
+ originalFiles: List,
settings: SettingsState,
- indicator: ProgressIndicator
+ indicator: ProgressIndicator,
+ onToken: (String) -> Unit
): Result = withContext(Dispatchers.IO) {
- val filteredFiles = DiffAnalyzer().filterFiles(result, settings)
+ val fileMap = originalFiles.associateBy { it.path }
+ val promptFiles = filteredFiles.mapNotNull { fileMap[it.path] }
- indicator.text = "Stage 1: 生成文件摘要..."
+ indicator.text = "生成 Commit Message..."
- // Stage 1: Generate per-file summaries
- val summaries = mutableListOf()
- for (file in filteredFiles) {
- if (indicator.isCanceled) {
- return@withContext Result.failure(Exception("已取消"))
- }
+ val diffContent = CommitPromptBuilder.buildDiffPreview(promptFiles)
+ val messages = listOf(
+ Message(MessageRole.SYSTEM, CommitPromptBuilder.buildStage2Prompt(settings.commitLanguage)),
+ Message(MessageRole.USER, CommitPromptBuilder.buildSingleStageUserMessage(diffContent, settings.commitLanguage))
+ )
+
+ val request = client.buildRequest(
+ config = provider,
+ apiKey = apiKey,
+ messages = messages,
+ temperature = 0.3,
+ maxTokens = 500,
+ stream = true
+ )
- val summary = generateFileSummary(file, settings, indicator)
- if (summary != null) {
- summaries.add(summary)
+ return@withContext client.streamCommit(request, onToken)
+ }
+
+ private suspend fun generateTwoStage(
+ filteredFiles: List,
+ originalFiles: List,
+ settings: SettingsState,
+ indicator: ProgressIndicator,
+ onToken: (String) -> Unit
+ ): Result = withContext(Dispatchers.IO) {
+ val fileMap = originalFiles.associateBy { it.path }
+
+ val total = filteredFiles.size
+ val completed = AtomicInteger(0)
+ indicator.text = "Stage 1: 生成文件摘要 (0/$total)..."
+
+ // Stage 1: Generate per-file summaries concurrently
+ val summaries = filteredFiles
+ .map { file ->
+ async {
+ val summary = generateFileSummary(file, fileMap[file.path], settings, indicator)
+ indicator.text = "Stage 1: 生成文件摘要 (${completed.incrementAndGet()}/$total)..."
+ summary
+ }
}
- }
+ .awaitAll()
+ .filterNotNull()
if (summaries.isEmpty()) {
return@withContext Result.failure(Exception("无法生成文件摘要"))
@@ -73,16 +120,17 @@ class TwoStageCommitGenerator(
messages = stage2Messages,
temperature = 0.3,
maxTokens = 500,
- stream = false
+ stream = true
)
- return@withContext client.callCommitSync(stage2Request)
+ return@withContext client.streamCommit(stage2Request, onToken)
}
private suspend fun generateFromStats(
result: DiffAnalyzer.AnalysisResult,
settings: SettingsState,
- indicator: ProgressIndicator
+ indicator: ProgressIndicator,
+ onToken: (String) -> Unit = {}
): Result = withContext(Dispatchers.IO) {
val filteredFiles = DiffAnalyzer().filterFiles(result, settings)
val statsSummary = DiffAnalyzer().buildStatsSummary(filteredFiles)
@@ -92,36 +140,44 @@ class TwoStageCommitGenerator(
Message(
MessageRole.USER,
CommitPromptBuilder.buildStatsUserMessage(
- filteredFiles.map {
- DiffAnalyzer.FileChange(it.path, it.additions, it.deletions, it.changeType)
- },
+ filteredFiles,
+ statsSummary,
settings.commitLanguage
)
)
)
+ indicator.text = "生成 Commit Message..."
+
val request = client.buildRequest(
config = provider,
apiKey = apiKey,
messages = messages,
temperature = 0.3,
maxTokens = 500,
- stream = false
+ stream = true
)
- return@withContext client.callCommitSync(request)
+ return@withContext client.streamCommit(request, onToken)
}
private suspend fun generateFileSummary(
file: DiffAnalyzer.FileChange,
+ originalFile: CommitPromptFile?,
settings: SettingsState,
indicator: ProgressIndicator
): String? = withContext(Dispatchers.IO) {
- indicator.text = "摘要: ${file.path}"
+ if (indicator.isCanceled) return@withContext null
+
+ val userMessage = if (originalFile != null) {
+ CommitPromptBuilder.buildSingleFilePrompt(originalFile)
+ } else {
+ CommitPromptBuilder.buildSingleFilePrompt(file)
+ }
val messages = listOf(
Message(MessageRole.SYSTEM, CommitPromptBuilder.buildStage1Prompt()),
- Message(MessageRole.USER, CommitPromptBuilder.buildSingleFilePrompt(file))
+ Message(MessageRole.USER, userMessage)
)
val request = client.buildRequest(
diff --git a/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt b/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt
index b150905..49f7a48 100644
--- a/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt
+++ b/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt
@@ -18,6 +18,7 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
+import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -27,6 +28,8 @@ import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
@Serializable
data class FunctionDefinition(
@@ -213,6 +216,30 @@ class OkHttpSseClient(
return eventSourceFactory.newEventSource(request, listener)
}
+ /**
+ * Streams the final commit message output, calling [onToken] for each visible token.
+ * Suppresses ... content from DeepSeek-style models during streaming.
+ * Returns the full cleaned content when done.
+ */
+ suspend fun streamCommit(request: Request, onToken: (String) -> Unit): Result =
+ suspendCancellableCoroutine { cont ->
+ val accumulated = StringBuilder()
+ val source = streamChat(
+ request = request,
+ onToken = { token ->
+ accumulated.append(token)
+ onToken(token)
+ },
+ onEnd = {
+ if (cont.isActive) cont.resume(Result.success(accumulated.toString()))
+ },
+ onError = { error ->
+ if (cont.isActive) cont.resume(Result.failure(Exception(error)))
+ }
+ )
+ cont.invokeOnCancellation { source.cancel() }
+ }
+
fun callSync(request: Request): Result {
return callWithClient(syncClient, request, "请求超时,请检查网络")
}