dsh-copilot-vision 1.0.0 → 1.3.1
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/README.md +61 -15
- package/package.json +2 -2
- package/src/actions.js +163 -28
- package/src/audit.js +3 -2
- package/src/auth.js +51 -82
- package/src/diff.js +8 -8
- package/src/keys.js +175 -0
- package/src/plugin.js +372 -98
- package/src/render.js +33 -20
- package/src/shot-index.js +27 -2
- package/src/summarize.js +131 -0
- package/src/wait.js +2 -1
package/src/render.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 位置编码渲染:把差分区域裁出来、按面积自适应倍率最近邻放大、叠加半透明红色网格,
|
|
2
2
|
// 让模型在「带标尺的放大镜」上数格子,而不是在全屏小图上目测偏移。
|
|
3
3
|
//
|
|
4
4
|
// 选型(不引新依赖):只用 pngjs + 手写 Bresenham。
|
|
@@ -14,11 +14,29 @@ import { resolveShotsDir } from './paths.js'
|
|
|
14
14
|
|
|
15
15
|
export const GRID_SCALE = 4
|
|
16
16
|
export const GRID_STEP_PHYSICAL = 20
|
|
17
|
+
// 4x 档的图内步距(放大图上每格像素数);其余档位 = GRID_STEP_PHYSICAL × 倍数。
|
|
17
18
|
export const GRID_STEP_IMAGE = GRID_SCALE * GRID_STEP_PHYSICAL // 80
|
|
18
19
|
|
|
19
|
-
// 4x
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// 自适应倍率阈值(物理像素面积):小区域 4x 保极限精度,中区域 2x 体积减半,
|
|
21
|
+
// 大区域(整页刷新级)1x 只画网格 —— 1920x1200 全屏若 4x = 7680x4800 ≈ 147MB RGBA,绝不可接受。
|
|
22
|
+
// 1x 后输出不可能超过源截图本身,所以不再需要旧的 250k 物理像素 gridSkipped 上限。
|
|
23
|
+
export const GRID_AREA_MAX_4X = 50_000
|
|
24
|
+
export const GRID_AREA_MAX_2X = 200_000
|
|
25
|
+
|
|
26
|
+
// 面积小但极细长的区域(4344x4 的边框/分隔线)会骗过面积档位:4x 后变成 17376x16 的超长图。
|
|
27
|
+
// 因此再按单边长度封顶:放大后任何一边都不得超过 2000 像素(即 4x 要求长边 <=500,2x 要求 <=1000)。
|
|
28
|
+
// 1x 不再放大,允许输出等于源区域本身,即使源区域长边超过 2000(裁剪尺寸不能凭空压缩)。
|
|
29
|
+
export const GRID_MAX_OUTPUT_EDGE = 2000
|
|
30
|
+
|
|
31
|
+
/** 按区域物理像素尺寸选放大倍数:面积定基础档位,单边上限再降档。 */
|
|
32
|
+
export function resolveGridScale(width, height) {
|
|
33
|
+
const area = width * height
|
|
34
|
+
const maxEdge = Math.max(width, height)
|
|
35
|
+
let scale = area <= GRID_AREA_MAX_4X ? 4 : area <= GRID_AREA_MAX_2X ? 2 : 1
|
|
36
|
+
if (scale === 4 && maxEdge * 4 > GRID_MAX_OUTPUT_EDGE) scale = 2
|
|
37
|
+
if (scale === 2 && maxEdge * 2 > GRID_MAX_OUTPUT_EDGE) scale = 1
|
|
38
|
+
return scale
|
|
39
|
+
}
|
|
22
40
|
|
|
23
41
|
const LINE_R = 255
|
|
24
42
|
const LINE_G = 0
|
|
@@ -59,7 +77,8 @@ function drawLine(png, x0, y0, x1, y1, alpha = GRID_ALPHA) {
|
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
/**
|
|
62
|
-
* 从源截图裁剪 bbox
|
|
80
|
+
* 从源截图裁剪 bbox、按 resolveGridScale(宽, 高) 自适应倍率最近邻放大、
|
|
81
|
+
* 每 GRID_STEP_PHYSICAL 物理像素画一条半透明红线,
|
|
63
82
|
* 落盘为 vp-grid-<shotId>-<index>.png。网格原点 = bbox 左上角(bbox 内第 0 个物理像素)。
|
|
64
83
|
*
|
|
65
84
|
* 文件名里的 shotId/index 不在规格签名里,但文件名要求它们,所以通过第 4 个参数传入;
|
|
@@ -85,13 +104,7 @@ export function generateGridImage(sourcePngPath, bbox, scaleFactor, { shotId, in
|
|
|
85
104
|
`收到 shotId=${shotId}, index=${index}`,
|
|
86
105
|
)
|
|
87
106
|
}
|
|
88
|
-
const
|
|
89
|
-
if (area > MAX_GRID_SOURCE_AREA) {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`generateGridImage: 区域 ${bbox.width}x${bbox.height}(${area} 像素)超过网格图上限 ` +
|
|
92
|
-
`${MAX_GRID_SOURCE_AREA},不放大(放大后缓冲为源面积的 16 倍)`,
|
|
93
|
-
)
|
|
94
|
-
}
|
|
107
|
+
const scale = resolveGridScale(bbox.width, bbox.height)
|
|
95
108
|
|
|
96
109
|
const source = PNG.sync.read(readFileSync(sourcePngPath))
|
|
97
110
|
if (bbox.x + bbox.width > source.width || bbox.y + bbox.height > source.height) {
|
|
@@ -101,28 +114,28 @@ export function generateGridImage(sourcePngPath, bbox, scaleFactor, { shotId, in
|
|
|
101
114
|
)
|
|
102
115
|
}
|
|
103
116
|
|
|
104
|
-
const outWidth = bbox.width *
|
|
105
|
-
const outHeight = bbox.height *
|
|
117
|
+
const outWidth = bbox.width * scale
|
|
118
|
+
const outHeight = bbox.height * scale
|
|
106
119
|
const out = new PNG({ width: outWidth, height: outHeight })
|
|
107
120
|
const rowBuf = Buffer.alloc(outWidth * 4)
|
|
108
121
|
|
|
109
122
|
for (let sy = 0; sy < bbox.height; sy++) {
|
|
110
123
|
const srcRow = ((bbox.y + sy) * source.width + bbox.x) * 4
|
|
111
124
|
for (let dx4 = 0; dx4 < outWidth * 4; dx4 += 4) {
|
|
112
|
-
// dx4/4 = 目标列,再除以
|
|
113
|
-
const from = srcRow + ((dx4 / (4 *
|
|
125
|
+
// dx4/4 = 目标列,再除以 scale 才是源列(最近邻:目标 scale 列共用 1 源像素)。
|
|
126
|
+
const from = srcRow + ((dx4 / (4 * scale)) | 0) * 4
|
|
114
127
|
rowBuf[dx4] = source.data[from]
|
|
115
128
|
rowBuf[dx4 + 1] = source.data[from + 1]
|
|
116
129
|
rowBuf[dx4 + 2] = source.data[from + 2]
|
|
117
130
|
rowBuf[dx4 + 3] = source.data[from + 3]
|
|
118
131
|
}
|
|
119
|
-
for (let k = 0; k <
|
|
120
|
-
rowBuf.copy(out.data, (sy *
|
|
132
|
+
for (let k = 0; k < scale; k++) {
|
|
133
|
+
rowBuf.copy(out.data, (sy * scale + k) * outWidth * 4)
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
for (let x = 0; x < outWidth; x +=
|
|
125
|
-
for (let y = 0; y < outHeight; y +=
|
|
137
|
+
for (let x = 0; x < outWidth; x += GRID_STEP_PHYSICAL * scale) drawLine(out, x, 0, x, outHeight - 1)
|
|
138
|
+
for (let y = 0; y < outHeight; y += GRID_STEP_PHYSICAL * scale) drawLine(out, 0, y, outWidth - 1, y)
|
|
126
139
|
|
|
127
140
|
const dir = resolveShotsDir()
|
|
128
141
|
mkdirSync(dir, { recursive: true })
|
package/src/shot-index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 落盘策略是有意的:JSON 不是行式格式,无法像审计日志那样 append,
|
|
6
6
|
// 所以每次 record 都用 writeFileSync 全量覆盖。MVP 假定单进程、工具调用串行,
|
|
7
7
|
// 因此不做并发保护(两个进程同时写会互相覆盖,属于已知取舍,不是隐藏 bug)。
|
|
8
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { resolveShotsDir } from './paths.js'
|
|
11
11
|
|
|
@@ -107,6 +107,31 @@ export function get(shotId) {
|
|
|
107
107
|
return load()[String(shotId)]
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* vp_summarize 把保留的截图移进 archives/ 后,同步改索引里的路径,
|
|
112
|
+
* 否则「关键截图」虽然还在盘上,差分却会读不到。
|
|
113
|
+
*/
|
|
114
|
+
export function relocate(shotId, newPath) {
|
|
115
|
+
const entry = get(shotId)
|
|
116
|
+
if (entry === undefined) return false
|
|
117
|
+
entry.path = newPath
|
|
118
|
+
flush()
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 索引条目还在、文件却被 vp_summarize 清掉时,给出确定的错误而不是裸 ENOENT。
|
|
124
|
+
* 条目本身刻意不删:nextShotId 靠索引最大键播种,删条目会让宿主重启后复用旧 shotId,
|
|
125
|
+
* 静默指向一张新截图 —— 那比「文件不存在」危险得多。
|
|
126
|
+
*/
|
|
127
|
+
export function assertShotFileExists(shotId, meta, toolName) {
|
|
128
|
+
if (!existsSync(meta.path)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${toolName}: shotId ${shotId} 的截图文件已不存在(可能已被 vp_summarize 清理),请重新截图`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
/**
|
|
111
136
|
* 把差分区域挂到某张截图的索引条目上。
|
|
112
137
|
* vp_click_relative 用 shotId + 1-based index 反查 bbox,所以区域必须随索引落盘 ——
|
|
@@ -118,7 +143,7 @@ export function recordRegions(shotId, regions) {
|
|
|
118
143
|
entry.regions = regions.map((region) => ({
|
|
119
144
|
bbox: { ...region.bbox },
|
|
120
145
|
localPath: region.localPath,
|
|
121
|
-
...(region.gridPath !== undefined ? { gridPath: region.gridPath } : {}),
|
|
146
|
+
...(region.gridPath !== undefined ? { gridPath: region.gridPath, gridScale: region.gridScale } : {}),
|
|
122
147
|
}))
|
|
123
148
|
flush()
|
|
124
149
|
return true
|
package/src/summarize.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 语义级记忆压缩:把关键截图归档,其余临时碎片一次清空。
|
|
2
|
+
//
|
|
3
|
+
// 与 paths.js 的 cleanupVisualFragments 的分工(两者不冲突):
|
|
4
|
+
// - cleanupVisualFragments 是「按时间的兜底」:只在插件启动时删 3 天前的 diff/grid,
|
|
5
|
+
// 永远不碰 vp-shot-*,永远不递归子目录。
|
|
6
|
+
// - 本函数是「按语义的主清理」:由模型在收工时显式调用,删所有 diff/grid 和没被保留的 vp-shot-*。
|
|
7
|
+
// 主清理的目标集合是兜底清理的超集,先跑谁都不会破坏另一方。
|
|
8
|
+
//
|
|
9
|
+
// 绝对不动的三样:audit.log(证据链)、shot-index.json(shotId 播种与相对点击依赖它)、archives/(归档本体)。
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { appendFile, mkdir, readdir, rename, stat, unlink } from 'node:fs/promises'
|
|
12
|
+
import { basename, join } from 'node:path'
|
|
13
|
+
import { resolveShotsDir } from './paths.js'
|
|
14
|
+
import { get, relocate } from './shot-index.js'
|
|
15
|
+
|
|
16
|
+
export const ARCHIVE_DIR_NAME = 'archives'
|
|
17
|
+
export const SUMMARY_FILE_NAME = 'archive-summary.txt'
|
|
18
|
+
|
|
19
|
+
export function archiveDir() {
|
|
20
|
+
return join(resolveShotsDir(), ARCHIVE_DIR_NAME)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function summaryPath() {
|
|
24
|
+
return join(resolveShotsDir(), SUMMARY_FILE_NAME)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DIFF_RE = /^vp-diff-\d+-\d+\.png$/
|
|
28
|
+
const GRID_RE = /^vp-grid-\d+-\d+\.png$/
|
|
29
|
+
const SHOT_RE = /^vp-shot-.*\.png$/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 收工清理。
|
|
33
|
+
* @param {{keepShotIds: number[], summary: string}} args
|
|
34
|
+
* @returns 归档/缺失清单与各类删除计数(供工具层回显)。
|
|
35
|
+
*/
|
|
36
|
+
export async function summarizeAction({ keepShotIds = [], summary } = {}) {
|
|
37
|
+
if (!Array.isArray(keepShotIds) || keepShotIds.some((id) => !Number.isInteger(id))) {
|
|
38
|
+
throw new Error(`vp_summarize: keepShotIds 必须是整数数组,收到 ${JSON.stringify(keepShotIds)}`)
|
|
39
|
+
}
|
|
40
|
+
if (typeof summary !== 'string' || summary.trim() === '') {
|
|
41
|
+
throw new Error('vp_summarize: summary 必须是非空字符串')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const dir = resolveShotsDir()
|
|
45
|
+
const archives = archiveDir()
|
|
46
|
+
await mkdir(archives, { recursive: true })
|
|
47
|
+
|
|
48
|
+
const archivedShotIds = []
|
|
49
|
+
const missingShotIds = []
|
|
50
|
+
const failedShotIds = []
|
|
51
|
+
const keepNames = new Set()
|
|
52
|
+
|
|
53
|
+
// 先归档再删除:顺序反了会把要保留的图当成「没被选中」删掉。
|
|
54
|
+
for (const shotId of new Set(keepShotIds)) {
|
|
55
|
+
const meta = get(shotId)
|
|
56
|
+
if (meta === undefined) {
|
|
57
|
+
missingShotIds.push(shotId)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
const name = basename(meta.path)
|
|
61
|
+
const sourcePath = join(dir, name)
|
|
62
|
+
const archivedPath = join(archives, name)
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (existsSync(sourcePath)) {
|
|
66
|
+
await rename(sourcePath, archivedPath)
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// 归档失败(文件被别的程序占用等)时绝不能继续往下走:
|
|
70
|
+
// 删循环会把仍在原位的源文件当成「没被保留」删掉。先把它记进 keepNames 保命。
|
|
71
|
+
keepNames.add(name)
|
|
72
|
+
failedShotIds.push(shotId)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
// 源和归档都不在,说明这张图已经丢过一次了,如实报缺失。
|
|
76
|
+
if (!existsSync(archivedPath)) {
|
|
77
|
+
missingShotIds.push(shotId)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
relocate(shotId, archivedPath)
|
|
82
|
+
keepNames.add(name)
|
|
83
|
+
archivedShotIds.push(shotId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const block = [
|
|
87
|
+
`[${new Date().toISOString()}] keep=${archivedShotIds.join(',') || '(none)'}`,
|
|
88
|
+
summary.trim(),
|
|
89
|
+
'',
|
|
90
|
+
].join('\n')
|
|
91
|
+
await appendFile(summaryPath(), block, 'utf8')
|
|
92
|
+
|
|
93
|
+
let removedShots = 0
|
|
94
|
+
let removedDiff = 0
|
|
95
|
+
let removedGrid = 0
|
|
96
|
+
let failedDeletes = 0
|
|
97
|
+
|
|
98
|
+
// 只扫顶层:archives/ 是目录,正则匹配不到;即便将来名字撞上,isFile 检查也会挡住。
|
|
99
|
+
for (const name of await readdir(dir)) {
|
|
100
|
+
const isDiff = DIFF_RE.test(name)
|
|
101
|
+
const isGrid = GRID_RE.test(name)
|
|
102
|
+
const isShot = SHOT_RE.test(name)
|
|
103
|
+
if (!isDiff && !isGrid && !isShot) continue
|
|
104
|
+
if (isShot && keepNames.has(name)) continue
|
|
105
|
+
try {
|
|
106
|
+
const info = await stat(join(dir, name))
|
|
107
|
+
if (!info.isFile()) continue
|
|
108
|
+
await unlink(join(dir, name))
|
|
109
|
+
if (isDiff) removedDiff++
|
|
110
|
+
else if (isGrid) removedGrid++
|
|
111
|
+
else removedShots++
|
|
112
|
+
} catch {
|
|
113
|
+
failedDeletes++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
summary: summary.trim(),
|
|
119
|
+
archivedShotIds,
|
|
120
|
+
missingShotIds,
|
|
121
|
+
failedShotIds,
|
|
122
|
+
failedArchives: failedShotIds.length,
|
|
123
|
+
removedShots,
|
|
124
|
+
removedDiff,
|
|
125
|
+
removedGrid,
|
|
126
|
+
removedTotal: removedShots + removedDiff + removedGrid,
|
|
127
|
+
failedDeletes,
|
|
128
|
+
archivesDir: archives,
|
|
129
|
+
summaryPath: summaryPath(),
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/wait.js
CHANGED
|
@@ -13,7 +13,7 @@ import { readFileSync } from 'node:fs'
|
|
|
13
13
|
import { PNG } from 'pngjs'
|
|
14
14
|
import pixelmatch from 'pixelmatch'
|
|
15
15
|
import { loadRobotTs } from './robot-ts.js'
|
|
16
|
-
import { get } from './shot-index.js'
|
|
16
|
+
import { get, assertShotFileExists } from './shot-index.js'
|
|
17
17
|
import { screenshotAction } from './actions.js'
|
|
18
18
|
import { diffPair, connectedComponents, DEFAULT_MIN_REGION_AREA } from './diff.js'
|
|
19
19
|
|
|
@@ -72,6 +72,7 @@ export async function waitChangeAction({
|
|
|
72
72
|
`vp_wait_change: baseline shotId ${baseline} 不存在,可先用 vp_screenshot 截一张`,
|
|
73
73
|
)
|
|
74
74
|
}
|
|
75
|
+
assertShotFileExists(baseline, baselineMeta, 'vp_wait_change')
|
|
75
76
|
const baselineImg = readPng(baselineMeta.path)
|
|
76
77
|
|
|
77
78
|
const { Session } = loadRobotTs()
|