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 -2
View File
@@ -234,14 +234,16 @@ export async function spawnClaude(opts: {
images?: ClaudeImage[]
profilePath?: string
bare?: boolean
/** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */
resumeSessionId?: string
}): Promise<ClaudeResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const hasImages = !!opts.images && opts.images.length > 0
if (!hasImages) {
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare)
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId)
}
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare)
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId)
}
function spawnClaudeText(
@@ -250,11 +252,13 @@ function spawnClaudeText(
timeoutMs: number,
profilePath?: string,
bare?: boolean,
resumeSessionId?: string,
): Promise<ClaudeResult> {
const args = [
...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions',
...(profilePath ? ['--settings', profilePath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
'-p',
prompt,
'--output-format',
@@ -361,6 +365,7 @@ function spawnClaudeStreamed(
images: ClaudeImage[],
profilePath?: string,
bare?: boolean,
resumeSessionId?: string,
): Promise<ClaudeResult> {
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must
// be paired with --output-format=stream-json (the friendlier `--output-format
@@ -370,6 +375,7 @@ function spawnClaudeStreamed(
...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions',
...(profilePath ? ['--settings', profilePath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
'-p',
'--input-format',
'stream-json',
+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[] }
+113
View File
@@ -0,0 +1,113 @@
/**
* Workspace 级「PR 文件改动说明 + 续会话 id」持久化。
*
* 落盘到 `<workspace>/.spx/pr-file-summaries.json`,形状 `Record<string, string>`。
* 用前缀给两类值分命名空间,避免「文件路径」与「会话 key」相撞:
* - `sum:${issueNumber}:${path}` → 该文件一句话改动说明(跨 push 保留,不随 head sha 失效)。
* - `ses:${issueNumber}` → 该工单最近一次 deepseek 生成会话的 sessionId,供下次 `--resume`。
*
* 文件不存在或解析失败时按空处理。
*/
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
type SummaryMap = Record<string, string>
function summaryFile(workspaceRoot: string): string {
return path.join(workspaceRoot, '.spx', 'pr-file-summaries.json')
}
function summaryKey(issueNumber: number, filePath: string): string {
return `sum:${issueNumber}:${filePath}`
}
function sessionKey(issueNumber: number): string {
return `ses:${issueNumber}`
}
/** 读出整张映射;文件不存在或解析失败返回空对象。 */
async function readMap(workspaceRoot: string): Promise<SummaryMap> {
let raw: string
try {
raw = await fsp.readFile(summaryFile(workspaceRoot), 'utf8')
}
catch {
return {}
}
try {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return {}
const out: SummaryMap = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string')
out[k] = v
}
return out
}
catch {
return {}
}
}
async function writeMap(workspaceRoot: string, map: SummaryMap): Promise<void> {
const dir = path.join(workspaceRoot, '.spx')
await fsp.mkdir(dir, { recursive: true })
await fsp.writeFile(summaryFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
}
/** 读某工单全部已生成的文件说明:`{ [path]: summary }`。无则空对象。 */
export async function readFileSummaries(
workspaceRoot: string,
issueNumber: number,
): Promise<Record<string, string>> {
const map = await readMap(workspaceRoot)
const prefix = `sum:${issueNumber}:`
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(map)) {
if (k.startsWith(prefix))
out[k.slice(prefix.length)] = v
}
return out
}
/** 写某文件的说明。空串则删除该 key。 */
export async function writeFileSummary(
workspaceRoot: string,
issueNumber: number,
filePath: string,
summary: string,
): Promise<void> {
const map = await readMap(workspaceRoot)
const key = summaryKey(issueNumber, filePath)
if (summary.length > 0)
map[key] = summary
else
delete map[key]
await writeMap(workspaceRoot, map)
}
/** 读某工单最近一次生成会话的 sessionId;无则 undefined。 */
export async function readSummarySession(
workspaceRoot: string,
issueNumber: number,
): Promise<string | undefined> {
const map = await readMap(workspaceRoot)
return map[sessionKey(issueNumber)] || undefined
}
/** 写某工单续会话 sessionId。传 undefined / 空串则删除(下次从头开会话)。 */
export async function writeSummarySession(
workspaceRoot: string,
issueNumber: number,
sessionId: string | undefined,
): Promise<void> {
const map = await readMap(workspaceRoot)
const key = sessionKey(issueNumber)
if (sessionId && sessionId.length > 0)
map[key] = sessionId
else
delete map[key]
await writeMap(workspaceRoot, map)
}