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