✨ 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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user