feat(vscode): 「改动」面板加审查勾 + deepseek 增量生成文件改动说明

右栏 diff 头部加一组按钮:
- 审查对勾:与左树同一状态源,勾上则文件路径标题置灰
-  一键调 deepseek 生成该文件「一句话改动目的」

增量复用会话:首个文件开新 cc 会话并把 sessionId 记到
.spx/pr-file-summaries.json,后续文件 --resume 接上去,省去每次重新
理解 PR 的开销;resume 失败则丢弃旧 id 重开一次。说明也落盘(跨 push
保留),重载后仍显示。

- spawnClaude 增 resumeSessionId(--resume)
- 新增 prFileSummaryStore;handler handleGeneratePrFileSummary
  用 jsdiff 拼 unified diff 喂模型
- 依赖 diff(jsdiff)
This commit is contained in:
2026-06-29 15:24:43 +08:00
parent fb505d59c5
commit 8c5cc90eda
11 changed files with 387 additions and 37 deletions
+8
View File
@@ -558,6 +558,14 @@ export class KanbanWebviewPanel {
})
return
}
if (msg.type === 'pr-file-summary/generate') {
void prFiles.handleGeneratePrFileSummary(this, {
issueNumber: msg.issueNumber,
path: msg.path,
previousPath: msg.previousPath,
})
return
}
if (msg.type === 'pr-review/set') {
void prFiles.handleSetPrReviewConfirmed(this, msg)
return
+113 -5
View File
@@ -8,12 +8,17 @@
*/
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { createPatch } from 'diff'
import { workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { listClaudeProfiles } from '../../cc/profiles'
import { spawnClaude } from '../../cc/spawnClaude'
import { detectRepo } from '../../git/remote'
import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api'
import { readStateJsonComment } from '../../gitea/stateJson'
import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore'
import { readConfirmed, writeConfirmed } from '../../sessions/prReviewStore'
import { makeNonce } from '../KanbanPanel'
/**
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined
@@ -106,13 +111,13 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [], summaries: {} })
return
}
const index = await resolvePrNumber(ctx, issueNumber)
if (index === undefined) {
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [], summaries: {} })
return
}
@@ -127,9 +132,12 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n
index,
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmed = workspaceRoot
? await readConfirmed(workspaceRoot, issueNumber, pr.headSha)
: []
const [confirmed, summaries] = workspaceRoot
? await Promise.all([
readConfirmed(workspaceRoot, issueNumber, pr.headSha),
readFileSummaries(workspaceRoot, issueNumber),
])
: [[], {}]
panel.postMessage({
type: 'pr-files/show',
issueNumber,
@@ -137,6 +145,7 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n
mergeBase,
files,
confirmed,
summaries,
})
}
catch (err) {
@@ -147,6 +156,7 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n
mergeBase: '',
files: [],
confirmed: [],
summaries: {},
error: err instanceof Error ? err.message : String(err),
})
}
@@ -220,3 +230,101 @@ export async function handleSetPrReviewConfirmed(
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
}
}
/** 生成说明的 prompt:首次带审查上下文设定,续会话只发新文件,让 deepseek 复用已有 PR 理解、省 token。 */
function buildSummaryPrompt(prNumber: number, filePath: string, patch: string, isFirst: boolean): string {
if (isFirst) {
return `我在逐个审查 PR #${prNumber} 的改动。请用一句简短中文说明下面这个文件本次改动的目的,只输出这一句,不要解释、不要代码块。\n\n文件: ${filePath}\n\n\`\`\`diff\n${patch}\n\`\`\``
}
return `下一个文件,同样用一句简短中文说明其改动目的,只输出这一句:\n文件: ${filePath}\n\`\`\`diff\n${patch}\n\`\`\``
}
/**
* 用 deepseek 给单个文件生成「一句话改动目的」。增量复用同一会话:首个文件开新会话并把
* sessionId 记到 `.spx/pr-file-summaries.json`,后续文件 `--resume` 接上去,省去每次重新理解
* PR 的开销。说明也落盘,重载后仍显示。
*
* diff 取 merge_base → head 两版原文用 jsdiff 拼 unified diff 喂给模型(比塞两份全文省 token)。
* spawnClaude 走 spawn 数组参数、不经 shell,故 patch 里的引号/换行无需转义。
*/
export async function handleGeneratePrFileSummary(
panel: KanbanWebviewPanel,
args: { issueNumber: number, path: string, previousPath?: string },
): Promise<void> {
const { issueNumber, path: filePath } = args
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const ctx = await resolveRepoContext(panel)
if (!ctx || !workspaceRoot) {
panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '当前工作区没有 Gitea 仓库或未配置 token' })
return
}
// deepseek profile 与「一键提交」同一来源;缺失则提示用户创建并清掉前端 loading。
const profiles = await listClaudeProfiles()
const deepseek = profiles.find(p => p.name === 'deepseek-v4-pro')
if (!deepseek) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '未找到 deepseek profile,请在 /home/cruldra/Sources/cruldra-profile/claude-config/profiles/ 下创建 deepseek.json',
dismissOnTimer: 8000,
})
panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '未找到 deepseek profile' })
return
}
const index = await resolvePrNumber(ctx, issueNumber)
if (index === undefined) {
panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '该工单还没有 PR' })
return
}
const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index })
const mergeBase = pr.mergeBase || pr.baseSha
const oldPath = args.previousPath ?? filePath
const [oldContent, newContent] = await Promise.all([
getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }),
getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: filePath, ref: pr.headSha }),
])
const patch = createPatch(filePath, oldContent, newContent, '', '')
const prevSession = await readSummarySession(workspaceRoot, issueNumber)
let result
try {
result = await spawnClaude({
prompt: buildSummaryPrompt(index, filePath, patch, !prevSession),
cwd: workspaceRoot,
profilePath: deepseek.path,
resumeSessionId: prevSession,
timeoutMs: 120_000,
})
}
catch (err) {
// resume 失败(会话过期/被清理)→ 丢弃旧 id,按首次重开一个会话再试一次。
if (!prevSession)
throw err
await writeSummarySession(workspaceRoot, issueNumber, undefined)
result = await spawnClaude({
prompt: buildSummaryPrompt(index, filePath, patch, true),
cwd: workspaceRoot,
profilePath: deepseek.path,
timeoutMs: 120_000,
})
}
const summary = result.resultText.trim().replace(/\s+/g, ' ')
await writeFileSummary(workspaceRoot, issueNumber, filePath, summary)
await writeSummarySession(workspaceRoot, issueNumber, result.sessionId)
panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, summary })
}
catch (err) {
panel.postMessage({
type: 'pr-file-summary/show',
issueNumber,
path: filePath,
error: err instanceof Error ? err.message : String(err),
})
}
}
+3 -1
View File
@@ -100,8 +100,9 @@ export type ExtensionToWebview
| { type: 'issue/remove', issueNumber: number }
| { type: 'profiles/show', data: ProfilesData }
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], error?: string }
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], summaries: Record<string, string>, error?: string }
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
| { type: 'pr-file-summary/show', issueNumber: number, path: string, summary?: string, error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
@@ -174,4 +175,5 @@ export type WebviewToExtension
| { type: 'managed-sessions/close-tab', sessionId: string }
| { type: 'pr-files/get', issueNumber: number }
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
| { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string }
| { type: 'pr-review/set', issueNumber: number, headSha: string, confirmed: string[] }