zen-gitsync 2.15.34 → 2.16.2
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 +37 -3
- package/package.json +1 -1
- package/src/cli/ai/agent.js +493 -83
- package/src/cli/ai/agent.test.js +190 -0
- package/src/cli/ai/images.js +211 -0
- package/src/cli/ai/images.test.js +98 -0
- package/src/cli/ai/modelSetup.js +594 -0
- package/src/cli/ai/modelSetup.test.js +496 -0
- package/src/cli/ai/platformGuard.js +200 -0
- package/src/cli/ai/platformGuard.test.js +253 -0
- package/src/cli/ai/streamFilter.js +82 -0
- package/src/cli/ai/streamFilter.test.js +88 -0
- package/src/cli/ai/termui.js +453 -0
- package/src/cli/ai/termui.test.js +253 -0
- package/src/cli/ai/tools.js +54 -18
- package/src/cli/ai/tools.test.js +28 -0
- package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
- package/src/ui/public/assets/BranchSelector-BISqgXoZ.js +1 -0
- package/src/ui/public/assets/CommitForm-C7FmI0Sl.js +6 -0
- package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
- package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
- package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
- package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CZdF1f1e.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
- package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
- package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
- package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
- package/src/ui/public/assets/MindmapView-CU3tXgbR.js +1 -0
- package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
- package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DHsJwTEX.js +5 -0
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
- package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
- package/src/ui/public/assets/SvgIcon-BqWp2WS8.js +1 -0
- package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
- package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
- package/src/ui/public/assets/WorkbenchView-1q0xaqyV.js +28 -0
- package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-B7s-K_Aj.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
- package/src/ui/public/assets/{configStore-DPGao81A.js → configStore-BkxDLXGa.js} +1 -1
- package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
- package/src/ui/public/assets/{flow-mindmap-5SDymYbY.js → flow-mindmap-DRmzYE4F.js} +1 -1
- package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
- package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
- package/src/ui/public/assets/socket-io-pmu5tRPo.js +1 -0
- package/src/ui/public/assets/{vendor-BteTDIoK.js → vendor-CcK8n8T4.js} +44 -37
- package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/ui/public/assets/AppVersionBadge-DL0Y81fU.js +0 -2
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
- package/src/ui/public/assets/BranchSelector-B_VMrSKv.js +0 -1
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
- package/src/ui/public/assets/CommitForm-BNuwp3ha.js +0 -6
- package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
- package/src/ui/public/assets/CommonDialog-C2dbNSrw.js +0 -1
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/EditorView-BdTQsQw7.js +0 -0
- package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-D5DBcy8S.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-omHN8HaT.js +0 -21
- package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
- package/src/ui/public/assets/LogList-Dc1RMEFq.js +0 -7
- package/src/ui/public/assets/MindmapView-BPTh-XAf.js +0 -1
- package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
- package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
- package/src/ui/public/assets/MonitorView-RTbnLZlO.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-CcQ8SaPb.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-Cr1yQ4pF.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
- package/src/ui/public/assets/SourceMapView-BbiYmCHB.js +0 -3
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
- package/src/ui/public/assets/SvgIcon-D3jL_b3c.js +0 -1
- package/src/ui/public/assets/UserInputNode-DCk_cvaT.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
- package/src/ui/public/assets/WorkbenchView-KX3nKGZc.js +0 -12
- package/src/ui/public/assets/element-plus-BAcS68uS.js +0 -24
- package/src/ui/public/assets/index-B8oXZO4w.js +0 -38
- package/src/ui/public/assets/socket-io-BC2cap_-.js +0 -1
- package/src/ui/public/assets/vue-flow-CtZ_ZWKZ.js +0 -39
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/cli/ai/agent.js 中 truncateDisplay(回显截断)的单元测试。
|
|
16
|
+
// 源自实测踩坑:git status 输出 614 字符被截断 —— 只省 14 字符没意义,
|
|
17
|
+
// 且切口把 "src/cli/ai/agent.js" 从中间切断显示成 "rc/cli/ai/agent.js"。
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { truncateDisplay, stripStaleImages, sanitizeMessages } from './agent.js'
|
|
21
|
+
|
|
22
|
+
const LIMIT = 600
|
|
23
|
+
|
|
24
|
+
test('短文本原样返回', () => {
|
|
25
|
+
assert.equal(truncateDisplay('hello'), 'hello')
|
|
26
|
+
assert.equal(truncateDisplay(''), '')
|
|
27
|
+
assert.equal(truncateDisplay(null), '')
|
|
28
|
+
assert.equal(truncateDisplay(undefined), '')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('只超出一点点(<200 字符)不截断 — git status 614 字符回归', () => {
|
|
32
|
+
// 构造恰好超过 limit 14 字符的文本(复现实测场景)
|
|
33
|
+
const text = 'x'.repeat(LIMIT + 14)
|
|
34
|
+
assert.equal(truncateDisplay(text, LIMIT), text)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('超过阈值才截断,且包含省略标记', () => {
|
|
38
|
+
const text = 'a'.repeat(LIMIT + 500)
|
|
39
|
+
const r = truncateDisplay(text, LIMIT)
|
|
40
|
+
assert.match(r, /回显省略 \d+ 字符/)
|
|
41
|
+
assert.ok(r.length < text.length)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('切口对齐整行边界,不把路径/单词从中间切断', () => {
|
|
45
|
+
// 每行 50 字符,构造 30 行的文本
|
|
46
|
+
const lines = Array.from({ length: 30 }, (_, i) => `line-${String(i).padStart(2, '0')}-${'x'.repeat(41)}`)
|
|
47
|
+
const text = lines.join('\n') // 30 * 50 + 29 ≈ 1529 字符,远超 limit
|
|
48
|
+
const r = truncateDisplay(text, LIMIT)
|
|
49
|
+
const parts = r.split('\n')
|
|
50
|
+
const markerIdx = parts.findIndex(l => l.includes('回显省略'))
|
|
51
|
+
assert.ok(markerIdx > 0, '应有省略标记行')
|
|
52
|
+
// 标记行之前的那行,必须是原文里的某一整行(不是半拉行)
|
|
53
|
+
const headLine = parts[markerIdx - 1]
|
|
54
|
+
assert.ok(lines.includes(headLine), `头部末行应为完整行,实际: ${headLine}`)
|
|
55
|
+
// 标记行之后的那行,也必须是原文里的某一整行
|
|
56
|
+
const tailLine = parts[markerIdx + 1]
|
|
57
|
+
assert.ok(lines.includes(tailLine), `尾部首行应为完整行,实际: ${tailLine}`)
|
|
58
|
+
// 尾部最后一行是原文最后一行
|
|
59
|
+
assert.ok(r.endsWith(lines[lines.length - 1]))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('单行超长文本也能截断(无换行可对齐时按字符截)', () => {
|
|
63
|
+
const text = 'y'.repeat(LIMIT + 500)
|
|
64
|
+
const r = truncateDisplay(text, LIMIT)
|
|
65
|
+
assert.match(r, /回显省略/)
|
|
66
|
+
assert.ok(r.startsWith('yyy'))
|
|
67
|
+
assert.ok(r.endsWith('yyy'))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ── stripStaleImages:多模态历史的图片瘦身 ──
|
|
71
|
+
test('stripStaleImages: 只保留最近一条带图消息的图片,更早的降级为占位文字', () => {
|
|
72
|
+
const img = (n) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${n}` } })
|
|
73
|
+
const messages = [
|
|
74
|
+
{ role: 'system', content: 'sys' },
|
|
75
|
+
{ role: 'user', content: [{ type: 'text', text: '第一问' }, img('AAA')] },
|
|
76
|
+
{ role: 'assistant', content: '第一答' },
|
|
77
|
+
{ role: 'user', content: [{ type: 'text', text: '第二问' }, img('BBB')] },
|
|
78
|
+
{ role: 'assistant', content: '第二答' },
|
|
79
|
+
{ role: 'user', content: [{ type: 'text', text: '第三问' }, img('CCC')] },
|
|
80
|
+
]
|
|
81
|
+
stripStaleImages(messages, 'zh-CN')
|
|
82
|
+
// 最近一条(第三问)图片原样保留
|
|
83
|
+
assert.equal(messages[5].content[1].type, 'image_url')
|
|
84
|
+
// 更早的图片变成文字占位
|
|
85
|
+
for (const i of [1, 3]) {
|
|
86
|
+
assert.equal(messages[i].content[1].type, 'text')
|
|
87
|
+
assert.match(messages[i].content[1].text, /图片/)
|
|
88
|
+
}
|
|
89
|
+
// 文本部件不动
|
|
90
|
+
assert.equal(messages[1].content[0].text, '第一问')
|
|
91
|
+
// 纯字符串 content 的消息不受影响
|
|
92
|
+
assert.equal(messages[0].content, 'sys')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('stripStaleImages: 没有图片时完全不动', () => {
|
|
96
|
+
const messages = [
|
|
97
|
+
{ role: 'system', content: 'sys' },
|
|
98
|
+
{ role: 'user', content: '纯文本问题' },
|
|
99
|
+
{ role: 'user', content: [{ type: 'text', text: '多部件但没图' }] },
|
|
100
|
+
]
|
|
101
|
+
const snapshot = JSON.parse(JSON.stringify(messages))
|
|
102
|
+
stripStaleImages(messages, 'zh-CN')
|
|
103
|
+
assert.deepEqual(messages, snapshot)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('stripStaleImages: en locale 用英文占位符', () => {
|
|
107
|
+
const messages = [
|
|
108
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:...' } }] },
|
|
109
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:...' } }] },
|
|
110
|
+
]
|
|
111
|
+
stripStaleImages(messages, 'en-US')
|
|
112
|
+
assert.equal(messages[0].content[0].text, '[image omitted from history]')
|
|
113
|
+
assert.equal(messages[1].content[0].type, 'image_url')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ── sanitizeMessages:空 content 消毒(防止 provider 报 2013) ──
|
|
117
|
+
test('sanitizeMessages: assistant 空 content 转 null(带 tool_calls 场景)', () => {
|
|
118
|
+
const messages = [
|
|
119
|
+
{ role: 'system', content: 'sys' },
|
|
120
|
+
{ role: 'user', content: 'hi' },
|
|
121
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'read_file', arguments: '{}' } }] },
|
|
122
|
+
{ role: 'tool', tool_call_id: 'tc1', name: 'read_file', content: 'file contents...' },
|
|
123
|
+
]
|
|
124
|
+
sanitizeMessages(messages)
|
|
125
|
+
assert.equal(messages[2].content, null, 'assistant 空 content 应转为 null')
|
|
126
|
+
assert.ok(messages[2].tool_calls, 'tool_calls 应保留不动')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('sanitizeMessages: assistant 空 content 转 null(无 tool_calls)', () => {
|
|
130
|
+
const messages = [
|
|
131
|
+
{ role: 'assistant', content: '' },
|
|
132
|
+
]
|
|
133
|
+
sanitizeMessages(messages)
|
|
134
|
+
assert.equal(messages[0].content, null)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('sanitizeMessages: tool 空 content 兜底为 (no output)', () => {
|
|
138
|
+
const messages = [
|
|
139
|
+
{ role: 'tool', tool_call_id: 'tc1', name: 'run_command', content: '' },
|
|
140
|
+
]
|
|
141
|
+
sanitizeMessages(messages)
|
|
142
|
+
assert.equal(messages[0].content, '(no output)')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('sanitizeMessages: user 空 content 兜底为空格', () => {
|
|
146
|
+
const messages = [
|
|
147
|
+
{ role: 'user', content: '' },
|
|
148
|
+
]
|
|
149
|
+
sanitizeMessages(messages)
|
|
150
|
+
assert.equal(messages[0].content, ' ')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('sanitizeMessages: 非空 content 不受影响', () => {
|
|
154
|
+
const messages = [
|
|
155
|
+
{ role: 'system', content: 'sys' },
|
|
156
|
+
{ role: 'user', content: 'hello' },
|
|
157
|
+
{ role: 'assistant', content: 'world' },
|
|
158
|
+
{ role: 'tool', tool_call_id: 'tc1', name: 'read_file', content: 'output' },
|
|
159
|
+
]
|
|
160
|
+
const snapshot = JSON.parse(JSON.stringify(messages))
|
|
161
|
+
sanitizeMessages(messages)
|
|
162
|
+
assert.deepEqual(messages, snapshot)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('sanitizeMessages: 数组 content(多模态)不受影响', () => {
|
|
166
|
+
const messages = [
|
|
167
|
+
{ role: 'user', content: [{ type: 'text', text: 'hi' }, { type: 'image_url', image_url: { url: 'data:...' } }] },
|
|
168
|
+
]
|
|
169
|
+
const snapshot = JSON.parse(JSON.stringify(messages))
|
|
170
|
+
sanitizeMessages(messages)
|
|
171
|
+
assert.deepEqual(messages, snapshot)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('sanitizeMessages: 模拟真实工具调用流程后消息数组无空 content', () => {
|
|
175
|
+
// 模拟:模型只返回 tool_calls 没有文本 → 工具执行 → 下次请求
|
|
176
|
+
const messages = [
|
|
177
|
+
{ role: 'system', content: 'sys prompt' },
|
|
178
|
+
{ role: 'user', content: '帮我读一下文件' },
|
|
179
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'read_file', arguments: '{"path":"a.js"}' } }] },
|
|
180
|
+
{ role: 'tool', tool_call_id: 'tc1', name: 'read_file', content: '1→hello' },
|
|
181
|
+
]
|
|
182
|
+
sanitizeMessages(messages)
|
|
183
|
+
// 消毒后所有消息的 content 都不应是空字符串
|
|
184
|
+
for (const m of messages) {
|
|
185
|
+
if (typeof m.content === 'string') {
|
|
186
|
+
assert.ok(m.content.length > 0, `role=${m.role} 的 content 不应为空字符串`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
assert.equal(messages[2].content, null, 'assistant 空 content 应为 null')
|
|
190
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 图片附件 — 剪贴板图片读取 + 本地图片转 data URL。
|
|
16
|
+
//
|
|
17
|
+
// 触发方式:REPL 里按 Alt+V(或 /image <路径> 附加本地文件)。
|
|
18
|
+
// 图片保存到 ~/.git-commit-tool/ai-images/,发送时编码为 base64 data URL,
|
|
19
|
+
// 以 OpenAI 多模态格式(image_url)拼进 user 消息。
|
|
20
|
+
//
|
|
21
|
+
// 平台实现:
|
|
22
|
+
// - Windows: powershell.exe -STA 调 System.Windows.Forms.Clipboard.GetImage()
|
|
23
|
+
// (Win10+ 自带 PowerShell 5.1,-STA 是剪贴板访问的必要条件)
|
|
24
|
+
// - macOS: osascript 读 «class PNGf» 剪贴板数据
|
|
25
|
+
// - Linux: Wayland 用 wl-paste,X11 用 xclip(都没有则报不支持)
|
|
26
|
+
//
|
|
27
|
+
// 注意:读的是**本机**剪贴板 — ssh/远程终端里剪贴板通常没有图片,
|
|
28
|
+
// 此时返回空,由调用方提示用户改用 /image <路径>。
|
|
29
|
+
|
|
30
|
+
import { execFile } from 'node:child_process'
|
|
31
|
+
import { promises as fsp } from 'node:fs'
|
|
32
|
+
import os from 'node:os'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
const IMAGE_DIR = path.join(os.homedir(), '.git-commit-tool', 'ai-images')
|
|
36
|
+
const MAX_KEEP = 20 // 目录里最多保留的历史图片数(超出按时间最旧先删)
|
|
37
|
+
const CLIP_TIMEOUT_MS = 15000 // 剪贴板读取超时(PowerShell 冷启动可能要几秒)
|
|
38
|
+
|
|
39
|
+
const MIME_BY_EXT = {
|
|
40
|
+
'.png': 'image/png',
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.jpeg': 'image/jpeg',
|
|
43
|
+
'.gif': 'image/gif',
|
|
44
|
+
'.webp': 'image/webp',
|
|
45
|
+
'.bmp': 'image/bmp',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ──────────────────────────────────────────────
|
|
49
|
+
// 小工具
|
|
50
|
+
// ──────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** execFile 的 Promise 包装;stdout/stderr 给 Buffer,调用方自行解码 */
|
|
53
|
+
function execFileP(cmd, args, options = {}) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
execFile(cmd, args, { timeout: CLIP_TIMEOUT_MS, windowsHide: true, ...options },
|
|
56
|
+
(err, stdout, stderr) => {
|
|
57
|
+
resolve({
|
|
58
|
+
ok: !err,
|
|
59
|
+
code: typeof err?.code === 'number' ? err.code : (err ? -1 : 0),
|
|
60
|
+
stdout: stdout ?? Buffer.alloc(0),
|
|
61
|
+
stderr: stderr ?? Buffer.alloc(0),
|
|
62
|
+
error: err ? String(err.message || err) : null,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatBytes(n) {
|
|
69
|
+
n = Number(n) || 0
|
|
70
|
+
if (n < 1024) return `${n} B`
|
|
71
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
72
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 清理历史图片:按修改时间保留最新 MAX_KEEP 张(尽力而为,失败不阻塞) */
|
|
76
|
+
async function pruneOldImages() {
|
|
77
|
+
try {
|
|
78
|
+
const names = await fsp.readdir(IMAGE_DIR)
|
|
79
|
+
const files = []
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (!name.startsWith('clip-') || !name.endsWith('.png')) continue
|
|
82
|
+
const full = path.join(IMAGE_DIR, name)
|
|
83
|
+
try {
|
|
84
|
+
const st = await fsp.stat(full)
|
|
85
|
+
files.push({ full, mtime: st.mtimeMs })
|
|
86
|
+
} catch { /* 单个文件失败忽略 */ }
|
|
87
|
+
}
|
|
88
|
+
files.sort((a, b) => b.mtime - a.mtime)
|
|
89
|
+
for (const f of files.slice(MAX_KEEP)) {
|
|
90
|
+
await fsp.unlink(f.full).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
} catch { /* 目录不存在等情况直接跳过 */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ──────────────────────────────────────────────
|
|
96
|
+
// 各平台剪贴板读取:成功把图片写入 file,失败返回 false
|
|
97
|
+
// ──────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function saveClipboardWindows(file) {
|
|
100
|
+
// -STA:Clipboard 必须运行在单线程单元
|
|
101
|
+
// 输出路径走环境变量传递 — 实测 -Command 后的位置参数会被**拼进命令串**再解析,
|
|
102
|
+
// 路径里的反斜杠直接变成语法错误(ParserError: UnexpectedToken)
|
|
103
|
+
const script = [
|
|
104
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;',
|
|
105
|
+
'$img = [System.Windows.Forms.Clipboard]::GetImage();',
|
|
106
|
+
'if ($null -eq $img) { exit 3 }',
|
|
107
|
+
'$img.Save($env:G_AI_CLIP_OUT, [System.Drawing.Imaging.ImageFormat]::Png);',
|
|
108
|
+
'$img.Dispose(); exit 0',
|
|
109
|
+
].join(' ')
|
|
110
|
+
const r = await execFileP('powershell.exe', [
|
|
111
|
+
'-NoProfile', '-NonInteractive', '-STA', '-Command', script,
|
|
112
|
+
], { env: { ...process.env, G_AI_CLIP_OUT: file } })
|
|
113
|
+
return r.ok && r.code === 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function saveClipboardMac(file) {
|
|
117
|
+
// «class PNGf» 是 AppleScript 的 PNG 剪贴板类型;剪贴板无图片时 osascript 非零退出
|
|
118
|
+
// 输出路径走 system attribute 读环境变量,避免路径拼进脚本字符串的引号问题
|
|
119
|
+
const script = [
|
|
120
|
+
'set outPath to system attribute "G_AI_CLIP_OUT"',
|
|
121
|
+
'set pngData to the clipboard as «class PNGf»',
|
|
122
|
+
'set fp to open for access (POSIX file outPath) with write permission',
|
|
123
|
+
'set eof fp to 0',
|
|
124
|
+
'write pngData to fp',
|
|
125
|
+
'close access fp',
|
|
126
|
+
].join('\n')
|
|
127
|
+
const r = await execFileP('osascript', ['-e', script], { env: { ...process.env, G_AI_CLIP_OUT: file } })
|
|
128
|
+
return r.ok && r.code === 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function saveClipboardLinux(file) {
|
|
132
|
+
// 优先 Wayland 的 wl-paste;失败再试 X11 的 xclip
|
|
133
|
+
const wayland = await execFileP('wl-paste', ['--no-newline', '--type', 'image/png'])
|
|
134
|
+
if (wayland.ok && wayland.stdout.length > 0) {
|
|
135
|
+
await fsp.writeFile(file, wayland.stdout)
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
const x11 = await execFileP('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'])
|
|
139
|
+
if (x11.ok && x11.stdout.length > 0) {
|
|
140
|
+
await fsp.writeFile(file, x11.stdout)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ──────────────────────────────────────────────
|
|
147
|
+
// 对外 API
|
|
148
|
+
// ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 读取剪贴板图片,保存为 PNG。
|
|
152
|
+
* @returns {Promise<{path: string, bytes: number} | null>}
|
|
153
|
+
* 成功返回文件路径与字节数;剪贴板无图片 / 平台不支持 / 超时返回 null
|
|
154
|
+
*/
|
|
155
|
+
export async function readClipboardImage() {
|
|
156
|
+
await fsp.mkdir(IMAGE_DIR, { recursive: true })
|
|
157
|
+
const file = path.join(IMAGE_DIR, `clip-${Date.now()}.png`)
|
|
158
|
+
|
|
159
|
+
let saved = false
|
|
160
|
+
try {
|
|
161
|
+
if (process.platform === 'win32') saved = await saveClipboardWindows(file)
|
|
162
|
+
else if (process.platform === 'darwin') saved = await saveClipboardMac(file)
|
|
163
|
+
else saved = await saveClipboardLinux(file)
|
|
164
|
+
} catch {
|
|
165
|
+
saved = false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!saved) {
|
|
169
|
+
await fsp.unlink(file).catch(() => {})
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const st = await fsp.stat(file)
|
|
175
|
+
if (st.size === 0) {
|
|
176
|
+
await fsp.unlink(file).catch(() => {})
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
pruneOldImages() // 不 await,后台清理
|
|
180
|
+
return { path: file, bytes: st.size }
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 校验本地图片文件,返回 {path, bytes};不存在/不是支持的格式返回 null
|
|
188
|
+
*/
|
|
189
|
+
export async function checkImageFile(filePath) {
|
|
190
|
+
try {
|
|
191
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
192
|
+
if (!MIME_BY_EXT[ext]) return null
|
|
193
|
+
const st = await fsp.stat(filePath)
|
|
194
|
+
if (!st.isFile() || st.size === 0) return null
|
|
195
|
+
return { path: filePath, bytes: st.size }
|
|
196
|
+
} catch {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 图片文件 → OpenAI 多模态格式需要的 base64 data URL
|
|
203
|
+
*/
|
|
204
|
+
export async function imageToDataUrl(filePath) {
|
|
205
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
206
|
+
const mime = MIME_BY_EXT[ext] || 'image/png'
|
|
207
|
+
const buf = await fsp.readFile(filePath)
|
|
208
|
+
return `data:${mime};base64,${buf.toString('base64')}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/cli/ai/images.js 单元测试。
|
|
16
|
+
// 不覆盖 readClipboardImage(依赖真实剪贴板与平台工具,CI 上不可控),
|
|
17
|
+
// 只测纯函数与本地文件逻辑。
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { promises as fsp } from 'node:fs'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import { formatBytes, checkImageFile, imageToDataUrl } from './images.js'
|
|
24
|
+
|
|
25
|
+
// 最小合法 PNG(1x1 透明像素),base64 编码共 68 字节
|
|
26
|
+
const PNG_1X1_B64 =
|
|
27
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
|
|
28
|
+
|
|
29
|
+
test('formatBytes: B / KB / MB 三档', () => {
|
|
30
|
+
assert.equal(formatBytes(0), '0 B')
|
|
31
|
+
assert.equal(formatBytes(512), '512 B')
|
|
32
|
+
assert.equal(formatBytes(2048), '2.0 KB')
|
|
33
|
+
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB')
|
|
34
|
+
assert.equal(formatBytes(undefined), '0 B')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('checkImageFile: 不存在的路径返回 null', async () => {
|
|
38
|
+
assert.equal(await checkImageFile(path.join(os.tmpdir(), 'g-ai-no-such-file.png')), null)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('checkImageFile: 不支持的扩展名返回 null', async () => {
|
|
42
|
+
const f = path.join(os.tmpdir(), `g-ai-test-${Date.now()}.txt`)
|
|
43
|
+
await fsp.writeFile(f, 'hello')
|
|
44
|
+
try {
|
|
45
|
+
assert.equal(await checkImageFile(f), null)
|
|
46
|
+
} finally {
|
|
47
|
+
await fsp.unlink(f)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('checkImageFile: 合法 PNG 返回路径与字节数', async () => {
|
|
52
|
+
const f = path.join(os.tmpdir(), `g-ai-test-${Date.now()}.png`)
|
|
53
|
+
const pngBuf = Buffer.from(PNG_1X1_B64, 'base64')
|
|
54
|
+
await fsp.writeFile(f, pngBuf)
|
|
55
|
+
try {
|
|
56
|
+
const r = await checkImageFile(f)
|
|
57
|
+
assert.ok(r, '应识别 PNG')
|
|
58
|
+
assert.equal(r.path, f)
|
|
59
|
+
assert.equal(r.bytes, pngBuf.length)
|
|
60
|
+
} finally {
|
|
61
|
+
await fsp.unlink(f)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('checkImageFile: 空文件返回 null', async () => {
|
|
66
|
+
const f = path.join(os.tmpdir(), `g-ai-test-empty-${Date.now()}.png`)
|
|
67
|
+
await fsp.writeFile(f, '')
|
|
68
|
+
try {
|
|
69
|
+
assert.equal(await checkImageFile(f), null)
|
|
70
|
+
} finally {
|
|
71
|
+
await fsp.unlink(f)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('imageToDataUrl: 生成 data URL 且 base64 内容可还原', async () => {
|
|
76
|
+
const f = path.join(os.tmpdir(), `g-ai-test-du-${Date.now()}.png`)
|
|
77
|
+
const original = Buffer.from(PNG_1X1_B64, 'base64')
|
|
78
|
+
await fsp.writeFile(f, original)
|
|
79
|
+
try {
|
|
80
|
+
const url = await imageToDataUrl(f)
|
|
81
|
+
assert.ok(url.startsWith('data:image/png;base64,'), `应为 png data URL,实际前缀: ${url.slice(0, 30)}`)
|
|
82
|
+
const b64 = url.slice('data:image/png;base64,'.length)
|
|
83
|
+
assert.ok(Buffer.from(b64, 'base64').equals(original), 'base64 应能还原原文件')
|
|
84
|
+
} finally {
|
|
85
|
+
await fsp.unlink(f)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('imageToDataUrl: jpg 扩展名映射到 image/jpeg', async () => {
|
|
90
|
+
const f = path.join(os.tmpdir(), `g-ai-test-du-${Date.now()}.jpg`)
|
|
91
|
+
await fsp.writeFile(f, Buffer.from('fake-jpeg-bytes'))
|
|
92
|
+
try {
|
|
93
|
+
const url = await imageToDataUrl(f)
|
|
94
|
+
assert.ok(url.startsWith('data:image/jpeg;base64,'))
|
|
95
|
+
} finally {
|
|
96
|
+
await fsp.unlink(f)
|
|
97
|
+
}
|
|
98
|
+
})
|