@muyichengshayu/promptx 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6
4
+
5
+ - 修复切换任务时中栏消息偶发没有自动滚到底部的问题:任务切换后会进入一段短暂的强制跟底窗口,在 run 历史刷新、异步渲染和内容补齐期间持续补滚;如果用户主动手势滚动,则立即退出自动跟随,避免抢控制。
6
+ - 代码变更弹窗新增二进制文件预览:图片支持变更前 / 变更后双栏预览并可点击查看大图,音频 / 视频 / PDF 支持内嵌预览,其它二进制文件会展示大小、类型与 hash 摘要,并提供受限范围内的文件下载入口。
7
+ - 收紧二进制 diff 预览安全边界:新增的 blob 接口现在仅允许读取当前 diff 范围内的文件,不再可借由任务级 diff 预览访问仓库中的任意文件;同时补齐大文件限制、历史快照缺失时的失败兜底与相关服务端测试。
8
+ - 优化二进制 diff 预览细节:文件列表会直接标记“二进制”并显示 MIME 类型;切换 `workspace / task / run` 或不同 run 时会正确重置预览状态,不再残留上一轮的图片或错误状态;图片预览层级也已提升,避免被代码变更弹窗遮挡。
9
+
10
+ ## 0.2.5
11
+
12
+ - 工作台新增任务级未读提醒:当非当前聚焦任务的一次 agent turn 进入终态后,任务卡片会显示未读红点,浏览器页签标题也会切换为“你有新消息”,帮助在多任务并行时更快发现新进展。
13
+ - 新增通知音能力与通用设置开关:通用设置里可单独控制是否播放任务未读提示音,默认关闭;同时补齐音频兼容、首次交互解锁与缓存刷新逻辑,减少浏览器不响或误触发的情况。
14
+ - 收敛未读状态细节与稳定性:当前任务前台聚焦时不会误记未读;切换任务后中栏 Agent 过滤会自动回到“全部”;未读去重缓存改为可回收并设置上限,避免工作台长时间打开后内存状态持续膨胀。
15
+ - 补齐未读通知相关回归测试,并修正页签标题国际化与主题色细节,确保中英文环境和不同主题下的体验更一致。
16
+
3
17
  ## 0.2.4
4
18
 
5
19
  - 重构远程命令安全模型:把远程 `shell` 命令放行从旧的 Relay 高权限开关收口到统一的 `remoteCommandSecurity` 配置,明确区分 `disabled / relay / trusted-proxy` 三种模式;本机 loopback 访问继续默认允许,远程入口则必须显式匹配对应安全模式。
@@ -18,6 +18,25 @@ const DIFF_REVIEW_CACHE_TTL_MS = 4000
18
18
  const DIFF_REVIEW_CACHE_MAX_ENTRIES = 80
19
19
  const FILE_DIFF_CACHE_TTL_MS = 8000
20
20
  const FILE_DIFF_CACHE_MAX_ENTRIES = 400
21
+ const MAX_BINARY_DIFF_BLOB_BYTES = Math.max(64 * 1024, Number(process.env.PROMPTX_GIT_DIFF_MAX_BINARY_BLOB_BYTES) || 8 * 1024 * 1024)
22
+
23
+ const BINARY_PREVIEW_MIME_TYPES = new Map([
24
+ ['.avif', 'image/avif'],
25
+ ['.bmp', 'image/bmp'],
26
+ ['.gif', 'image/gif'],
27
+ ['.ico', 'image/x-icon'],
28
+ ['.jpeg', 'image/jpeg'],
29
+ ['.jpg', 'image/jpeg'],
30
+ ['.mp3', 'audio/mpeg'],
31
+ ['.mp4', 'video/mp4'],
32
+ ['.ogg', 'audio/ogg'],
33
+ ['.ogv', 'video/ogg'],
34
+ ['.pdf', 'application/pdf'],
35
+ ['.png', 'image/png'],
36
+ ['.wav', 'audio/wav'],
37
+ ['.webm', 'video/webm'],
38
+ ['.webp', 'image/webp'],
39
+ ])
21
40
 
22
41
  const diffReviewCache = new Map()
23
42
  const fileDiffCache = new Map()
@@ -103,6 +122,38 @@ function createHash(value) {
103
122
  return crypto.createHash('sha1').update(value).digest('hex')
104
123
  }
105
124
 
125
+ function inferBinaryMimeType(filePath = '') {
126
+ return BINARY_PREVIEW_MIME_TYPES.get(path.extname(String(filePath || '').trim()).toLowerCase()) || 'application/octet-stream'
127
+ }
128
+
129
+ function inferBinaryPreviewKind(mimeType = '') {
130
+ const normalizedMimeType = String(mimeType || '').trim().toLowerCase()
131
+ if (normalizedMimeType.startsWith('image/')) {
132
+ return 'image'
133
+ }
134
+ if (normalizedMimeType.startsWith('audio/')) {
135
+ return 'audio'
136
+ }
137
+ if (normalizedMimeType.startsWith('video/')) {
138
+ return 'video'
139
+ }
140
+ if (normalizedMimeType === 'application/pdf') {
141
+ return 'pdf'
142
+ }
143
+ return 'binary'
144
+ }
145
+
146
+ function isKnownBinaryPreviewPath(filePath = '') {
147
+ return BINARY_PREVIEW_MIME_TYPES.has(path.extname(String(filePath || '').trim()).toLowerCase())
148
+ }
149
+
150
+ function createApiError(message = '', statusCode = 400, messageKey = 'errors.requestFailed') {
151
+ const error = new Error(message)
152
+ error.statusCode = statusCode
153
+ error.messageKey = messageKey
154
+ return error
155
+ }
156
+
106
157
  function countTextLines(value = '') {
107
158
  const text = String(value || '')
108
159
  if (!text) {
@@ -419,7 +470,7 @@ function readFileState(repoRoot = '', filePath = '') {
419
470
  }
420
471
 
421
472
  const buffer = fs.readFileSync(absolutePath)
422
- const isBinary = buffer.includes(0)
473
+ const isBinary = isKnownBinaryPreviewPath(filePath) || buffer.includes(0)
423
474
  const tooLarge = !isBinary && buffer.length > MAX_SNAPSHOT_TEXT_BYTES
424
475
 
425
476
  return {
@@ -489,7 +540,7 @@ function readHeadFileState(repoRoot = '', headOid = '', filePath = '') {
489
540
  }
490
541
 
491
542
  const buffer = result.stdout
492
- const isBinary = buffer.includes(0)
543
+ const isBinary = isKnownBinaryPreviewPath(normalizedPath) || buffer.includes(0)
493
544
  const tooLarge = !isBinary && buffer.length > MAX_SNAPSHOT_TEXT_BYTES
494
545
 
495
546
  return {
@@ -502,6 +553,126 @@ function readHeadFileState(repoRoot = '', headOid = '', filePath = '') {
502
553
  }
503
554
  }
504
555
 
556
+ function normalizeRepoFilePath(filePath = '') {
557
+ const rawPath = String(filePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '')
558
+ if (!rawPath || rawPath.includes('\0')) {
559
+ return ''
560
+ }
561
+
562
+ const normalizedPath = path.posix.normalize(rawPath)
563
+ if (!normalizedPath || normalizedPath === '.' || normalizedPath === '..' || normalizedPath.startsWith('../')) {
564
+ return ''
565
+ }
566
+
567
+ return normalizedPath
568
+ }
569
+
570
+ function createBlobPayload(filePath = '', side = '', buffer = Buffer.alloc(0)) {
571
+ const mimeType = inferBinaryMimeType(filePath)
572
+ return {
573
+ supported: true,
574
+ filePath,
575
+ side,
576
+ mimeType,
577
+ previewKind: inferBinaryPreviewKind(mimeType),
578
+ size: buffer.length,
579
+ hash: createHash(buffer),
580
+ body: buffer,
581
+ }
582
+ }
583
+
584
+ function createUnsupportedBlobPayload(message = '', statusCode = 404, messageKey = 'errors.gitDiffFailed') {
585
+ return {
586
+ supported: false,
587
+ statusCode,
588
+ messageKey,
589
+ message: String(message || '无法读取该文件内容。'),
590
+ filePath: '',
591
+ side: '',
592
+ mimeType: '',
593
+ previewKind: 'binary',
594
+ size: 0,
595
+ hash: '',
596
+ body: Buffer.alloc(0),
597
+ }
598
+ }
599
+
600
+ function enforceBlobSize(buffer = Buffer.alloc(0)) {
601
+ if (buffer.length > MAX_BINARY_DIFF_BLOB_BYTES) {
602
+ throw createApiError('文件较大,暂不支持在线预览。', 413, 'diffReview.binaryPreviewTooLarge')
603
+ }
604
+ }
605
+
606
+ function readHeadBlob(repoRoot = '', headOid = '', filePath = '', side = '') {
607
+ const normalizedHeadOid = String(headOid || '').trim()
608
+ const normalizedPath = normalizeRepoFilePath(filePath)
609
+ if (!repoRoot || !normalizedHeadOid || !normalizedPath) {
610
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
611
+ }
612
+
613
+ const result = runGitBuffer(repoRoot, ['show', `${normalizedHeadOid}:${normalizedPath}`], {
614
+ maxBuffer: MAX_BINARY_DIFF_BLOB_BYTES + 1024,
615
+ })
616
+ if (result.status !== 0) {
617
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
618
+ }
619
+
620
+ enforceBlobSize(result.stdout)
621
+ return createBlobPayload(normalizedPath, side, result.stdout)
622
+ }
623
+
624
+ function readWorkingTreeBlob(repoRoot = '', filePath = '', side = '') {
625
+ const normalizedPath = normalizeRepoFilePath(filePath)
626
+ if (!repoRoot || !normalizedPath) {
627
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
628
+ }
629
+
630
+ const root = path.resolve(repoRoot)
631
+ const absolutePath = path.resolve(root, normalizedPath)
632
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
633
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
634
+ }
635
+
636
+ try {
637
+ const stats = fs.statSync(absolutePath)
638
+ if (!stats.isFile()) {
639
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
640
+ }
641
+ if (stats.size > MAX_BINARY_DIFF_BLOB_BYTES) {
642
+ throw createApiError('文件较大,暂不支持在线预览。', 413, 'diffReview.binaryPreviewTooLarge')
643
+ }
644
+
645
+ return createBlobPayload(normalizedPath, side, fs.readFileSync(absolutePath))
646
+ } catch (error) {
647
+ if (error?.statusCode) {
648
+ throw error
649
+ }
650
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
651
+ }
652
+ }
653
+
654
+ function readSnapshotStateBlob(repoRoot = '', filePath = '', state = null, side = '') {
655
+ if (!state?.exists) {
656
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
657
+ }
658
+
659
+ if (!state.isBinary && !state.tooLarge) {
660
+ const buffer = Buffer.from(String(state.text || ''), 'utf8')
661
+ return createBlobPayload(filePath, side, buffer)
662
+ }
663
+
664
+ const livePayload = readWorkingTreeBlob(repoRoot, filePath, side)
665
+ if (livePayload.supported && String(livePayload.hash || '') === String(state.hash || '')) {
666
+ return livePayload
667
+ }
668
+
669
+ return createUnsupportedBlobPayload(
670
+ '历史快照中没有保存该二进制文件内容。',
671
+ 404,
672
+ 'diffReview.binarySnapshotUnavailable'
673
+ )
674
+ }
675
+
505
676
  function createBaselineStateResolver(repoRoot = '', baseline = null) {
506
677
  const cache = new Map()
507
678
 
@@ -1379,6 +1550,40 @@ function sortDiffFiles(items = []) {
1379
1550
  })
1380
1551
  }
1381
1552
 
1553
+ function buildDiffFileSideMeta(filePath = '', state = null) {
1554
+ const exists = Boolean(state?.exists)
1555
+ const mimeType = exists ? inferBinaryMimeType(filePath) : ''
1556
+ return {
1557
+ exists,
1558
+ size: exists ? Math.max(0, Number(state?.size) || 0) : 0,
1559
+ hash: exists ? String(state?.hash || '') : '',
1560
+ hashShort: exists ? String(state?.hash || '').slice(0, 7) : '',
1561
+ mimeType,
1562
+ previewKind: exists ? inferBinaryPreviewKind(mimeType) : 'none',
1563
+ tooLarge: exists ? Math.max(0, Number(state?.size) || 0) > MAX_BINARY_DIFF_BLOB_BYTES : false,
1564
+ }
1565
+ }
1566
+
1567
+ function buildBinaryPreviewMeta(filePath = '', previousState = null, nextState = null, patchPayload = {}) {
1568
+ const hasBinarySide = Boolean(previousState?.isBinary || nextState?.isBinary || patchPayload.binary)
1569
+ if (!hasBinarySide) {
1570
+ return null
1571
+ }
1572
+
1573
+ const before = buildDiffFileSideMeta(filePath, previousState)
1574
+ const after = buildDiffFileSideMeta(filePath, nextState)
1575
+ const previewKind = after.exists ? after.previewKind : before.previewKind
1576
+ const mimeType = after.exists ? after.mimeType : before.mimeType
1577
+
1578
+ return {
1579
+ kind: previewKind,
1580
+ mimeType,
1581
+ maxPreviewBytes: MAX_BINARY_DIFF_BLOB_BYTES,
1582
+ before,
1583
+ after,
1584
+ }
1585
+ }
1586
+
1382
1587
  function createDiffFileEntry(filePath = '', previousState = null, nextState = null, options = {}) {
1383
1588
  if (areFileStatesEqual(previousState, nextState)) {
1384
1589
  return null
@@ -1413,6 +1618,7 @@ function createDiffFileEntry(filePath = '', previousState = null, nextState = nu
1413
1618
  patch: patchPayload.patch,
1414
1619
  patchLoaded: patchPayload.patchLoaded,
1415
1620
  message: patchPayload.message,
1621
+ binaryPreview: buildBinaryPreviewMeta(filePath, previousState, nextState, patchPayload),
1416
1622
  }
1417
1623
  }
1418
1624
 
@@ -1921,6 +2127,100 @@ export function getTaskGitDiffReview(taskSlug = '', options = {}) {
1921
2127
  return payload
1922
2128
  }
1923
2129
 
2130
+ export function getTaskGitDiffBlob(taskSlug = '', options = {}) {
2131
+ const normalizedTaskSlug = String(taskSlug || '').trim()
2132
+ const filePath = normalizeRepoFilePath(options.filePath)
2133
+ const side = String(options.side || '').trim() === 'before' ? 'before' : 'after'
2134
+ const rawScope = String(options.scope || 'workspace').trim()
2135
+ const scope = rawScope === 'run'
2136
+ ? 'run'
2137
+ : rawScope === 'task'
2138
+ ? 'task'
2139
+ : 'workspace'
2140
+ const runId = String(options.runId || '').trim()
2141
+
2142
+ if (!normalizedTaskSlug || !filePath) {
2143
+ return createUnsupportedBlobPayload('文件不存在。', 404, 'errors.fileNotFound')
2144
+ }
2145
+
2146
+ const diffPayload = getTaskGitDiffReview(normalizedTaskSlug, {
2147
+ scope,
2148
+ runId,
2149
+ filePath,
2150
+ includeStats: false,
2151
+ })
2152
+ const fileInDiff = Boolean(
2153
+ diffPayload?.supported
2154
+ && Array.isArray(diffPayload.files)
2155
+ && diffPayload.files.some((item) => String(item?.path || '').trim() === filePath)
2156
+ )
2157
+ if (!fileInDiff) {
2158
+ return createUnsupportedBlobPayload('当前文件不在本次 diff 范围内。', 404, 'diffReview.fileNotInDiff')
2159
+ }
2160
+
2161
+ if (scope === 'workspace') {
2162
+ const repoRoot = resolveTaskRepoRoot(normalizedTaskSlug)
2163
+ if (!repoRoot) {
2164
+ return createUnsupportedBlobPayload('当前工作目录不是 Git 仓库,暂不支持代码变更审查。')
2165
+ }
2166
+ if (side === 'before') {
2167
+ return readHeadBlob(repoRoot, resolveGitHeadOid(repoRoot), filePath, side)
2168
+ }
2169
+ return readWorkingTreeBlob(repoRoot, filePath, side)
2170
+ }
2171
+
2172
+ let baseline = null
2173
+ let comparisonSnapshot = null
2174
+ if (scope === 'run') {
2175
+ if (!runId || getRunTaskSlug(runId) !== normalizedTaskSlug) {
2176
+ return createUnsupportedBlobPayload('没有找到对应的执行记录。', 404, 'diffReview.runNotFound')
2177
+ }
2178
+ baseline = loadRunBaseline(runId)
2179
+ comparisonSnapshot = loadRunFinalSnapshot(runId)
2180
+ } else {
2181
+ baseline = loadTaskBaseline(normalizedTaskSlug)
2182
+ }
2183
+
2184
+ if (!baseline) {
2185
+ return createUnsupportedBlobPayload(
2186
+ scope === 'run'
2187
+ ? '这轮执行还没有建立代码变更基线,暂时无法查看本轮 diff。'
2188
+ : '当前任务还没有建立代码变更基线,请先让 Codex 执行一轮。',
2189
+ 404,
2190
+ scope === 'run' ? 'diffReview.runBaselineMissing' : 'diffReview.taskBaselineMissing'
2191
+ )
2192
+ }
2193
+
2194
+ if (scope === 'run' && !comparisonSnapshot) {
2195
+ return createUnsupportedBlobPayload(
2196
+ '这轮执行缺少结束快照,暂时无法准确还原本轮代码变更。',
2197
+ 404,
2198
+ 'diffReview.runSnapshotMissing'
2199
+ )
2200
+ }
2201
+
2202
+ const repoRoot = resolveGitRepoRoot(baseline.repoRoot)
2203
+ if (!repoRoot) {
2204
+ return createUnsupportedBlobPayload('原工作目录已不是有效的 Git 仓库,暂时无法读取代码变更。')
2205
+ }
2206
+
2207
+ if (side === 'before') {
2208
+ if (baseline.entries.has(filePath)) {
2209
+ return readSnapshotStateBlob(repoRoot, filePath, baseline.entries.get(filePath), side)
2210
+ }
2211
+ return readHeadBlob(repoRoot, baseline.headOid, filePath, side)
2212
+ }
2213
+
2214
+ if (scope === 'run') {
2215
+ if (comparisonSnapshot.entries.has(filePath)) {
2216
+ return readSnapshotStateBlob(repoRoot, filePath, comparisonSnapshot.entries.get(filePath), side)
2217
+ }
2218
+ return readHeadBlob(repoRoot, comparisonSnapshot.headOid, filePath, side)
2219
+ }
2220
+
2221
+ return readWorkingTreeBlob(repoRoot, filePath, side)
2222
+ }
2223
+
1924
2224
  export function __resetGitDiffCachesForTest() {
1925
2225
  diffReviewCache.clear()
1926
2226
  fileDiffCache.clear()
@@ -22,6 +22,7 @@ import {
22
22
  updateTask,
23
23
  } from './repository.js'
24
24
  import {
25
+ getTaskGitDiffBlob,
25
26
  getWorkspaceGitDiffStatusSummaryByCwd,
26
27
  } from './gitDiff.js'
27
28
  import {
@@ -389,6 +390,7 @@ registerTaskRoutes(app, {
389
390
  getPromptxCodexSessionById,
390
391
  getRunningCodexRunByTaskSlug,
391
392
  getTaskBySlug,
393
+ getTaskGitDiffBlob,
392
394
  getTaskGitDiffReviewInSubprocess,
393
395
  listTaskCodexRunsWithOptions,
394
396
  listTaskWorkspaceDiffSummaries: taskWorkspaceDiffSummaryService.listTaskWorkspaceDiffSummaries,
@@ -204,6 +204,13 @@ function registerTaskRoutes(app, options = {}) {
204
204
  getSystemConfig = getSystemConfigForRuntime,
205
205
  getRunningCodexRunByTaskSlug,
206
206
  getTaskBySlug,
207
+ getTaskGitDiffBlob = () => ({
208
+ supported: false,
209
+ statusCode: 404,
210
+ messageKey: 'errors.gitDiffFailed',
211
+ message: '无法读取该文件内容。',
212
+ body: Buffer.alloc(0),
213
+ }),
207
214
  getTaskGitDiffReviewInSubprocess,
208
215
  listTaskCodexRunsWithOptions,
209
216
  listTaskWorkspaceDiffSummaries,
@@ -444,6 +451,51 @@ function registerTaskRoutes(app, options = {}) {
444
451
  }
445
452
  })
446
453
 
454
+ app.get('/api/tasks/:slug/git-diff/blob', async (request, reply) => {
455
+ purgeExpiredContent()
456
+ const task = getTaskBySlug(request.params.slug)
457
+ if (!task || task.expired) {
458
+ return reply.code(404).send({ messageKey: 'errors.taskNotFound', message: '任务不存在。' })
459
+ }
460
+
461
+ const scope = String(request.query?.scope || 'workspace').trim()
462
+ if (scope !== 'workspace' && scope !== 'task' && scope !== 'run') {
463
+ return reply.code(400).send({ messageKey: 'errors.invalidDiffScope', message: '无效的 diff 范围。' })
464
+ }
465
+
466
+ try {
467
+ const payload = getTaskGitDiffBlob(request.params.slug, {
468
+ scope,
469
+ runId: request.query?.runId,
470
+ filePath: request.query?.filePath,
471
+ side: request.query?.side,
472
+ })
473
+ if (!payload?.supported) {
474
+ return reply
475
+ .code(payload?.statusCode || 404)
476
+ .send({
477
+ messageKey: payload?.messageKey || 'errors.gitDiffFailed',
478
+ message: payload?.message || '无法读取该文件内容。',
479
+ })
480
+ }
481
+
482
+ return reply
483
+ .header('cache-control', 'no-store')
484
+ .header('x-promptx-file-size', String(payload.size || 0))
485
+ .header('x-promptx-file-hash', String(payload.hash || ''))
486
+ .type(payload.mimeType || 'application/octet-stream')
487
+ .send(payload.body)
488
+ } catch (error) {
489
+ if (error?.statusCode) {
490
+ return reply.code(error.statusCode).send(getApiErrorPayload(error, {
491
+ messageKey: error.messageKey || 'errors.gitDiffFailed',
492
+ message: String(error?.message || '无法读取该文件内容。'),
493
+ }))
494
+ }
495
+ throw error
496
+ }
497
+ })
498
+
447
499
  app.post('/api/tasks/:slug/codex-runs', async (request, reply) => {
448
500
  purgeExpiredContent()
449
501
  try {