zen-gitsync 2.17.38 → 2.17.39
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 +34 -2
- package/package.json +3 -1
- package/src/ui/public/assets/{AgentEngineSelector-BMJAsMx_.js → AgentEngineSelector-Ca7h-OVv.js} +1 -1
- package/src/ui/public/assets/{AgentView-BH5Ec4j1.js → AgentView-KzzF7_Sv.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-DNG70v26.js → AppVersionBadge-B8VigVla.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-DBI8ieGq.js → BranchSelector-DgEH8i2U.js} +1 -1
- package/src/ui/public/assets/{CommitForm-BHwc5sEG.js → CommitForm-24MRGz_j.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-DuI8Bsg7.js → CommonDialog-C3ruJAse.js} +1 -1
- package/src/ui/public/assets/{EditorView-B3EO-mxZ.js → EditorView-BtlRxw4L.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-DtGtQGLR.js → FileDiffViewer-B5cXoziJ.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-sM9EwU3Q.js → FlowExecutionViewer-DtFInmRe.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-0Kh0IoGP.js → FlowOrchestrationWorkspace-BC5nzN88.js} +1 -1
- package/src/ui/public/assets/{IconButton-C07zojvw.js → IconButton-CmaM18jB.js} +1 -1
- package/src/ui/public/assets/{LogList-BatjYplm.js → LogList-DiMQ_7yP.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CTHfsYTC.js → MindmapView-CqOAPpFu.js} +1 -1
- package/src/ui/public/assets/{MonitorView-BblFdPFM.js → MonitorView-POiXYq_a.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CFQKlRoA.js → ProjectStartupButton-DnxiVsCj.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-BVgA8lLY.js → RecentDirectoriesChat-BTJsk605.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-BxD0m5c-.js → RemoteManagerDialog-BgEilA7I.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-kOIERvah.js → RemoteRepoCard-CcZ04i_i.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-Bs3DYqDP.js → SourceMapView-BkijspIL.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-YQZtv72i.js → SvgIcon-CBqXx2qp.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-zSKmah02.js → TemplateManager-Bd9A3dXY.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DMCLoOUw.js → UserInputNode-DYXVDPO-.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CIhuba2j.js +232 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-DcQKU19k.js → _plugin-vue_export-helper-DqKDIY1f.js} +2 -2
- package/src/ui/public/assets/{agentConversations-D_TsndCC.js → agentConversations-CVTMnWX1.js} +1 -1
- package/src/ui/public/assets/{configStore-u3FUM08v.js → configStore-CqbN-y1x.js} +1 -1
- package/src/ui/public/assets/{gitStore-ZjHYEyKS.js → gitStore-BrDeYGjs.js} +1 -1
- package/src/ui/public/assets/index-DTR0zSQP.css +1 -0
- package/src/ui/public/assets/{index-DorcTBZN.js → index-DkTmqIha.js} +65 -60
- package/src/ui/public/assets/{previewSandbox-KLKRAhm4.js → previewSandbox-DAqiCX7e.js} +1 -1
- package/src/ui/public/assets/{toolsStore-CfyhqJeq.js → toolsStore-DVWI6-BS.js} +1 -1
- package/src/ui/public/index.html +12 -12
- package/src/ui/server/index.js +9 -0
- package/src/ui/server/routes/projectStartupAi.js +521 -0
- package/src/ui/server/routes/projectStartupAi.test.js +378 -0
- package/src/ui/server/routes/workbench/orchestratorStore.js +5 -5
- package/src/ui/server/routes/workbench/orchestratorStore.progressReport.test.js +27 -0
- package/src/ui/server/routes/workbench/progressReport.js +15 -0
- package/src/ui/public/assets/WorkbenchView-nHzg7gau.js +0 -231
- package/src/ui/public/assets/index-BaV4YcQ6.css +0 -1
|
@@ -0,0 +1,378 @@
|
|
|
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
|
+
// 「AI 推荐启动方式」的回归测试。
|
|
16
|
+
//
|
|
17
|
+
// 覆盖四件事:
|
|
18
|
+
// 1. collectProjectFacts —— 扫出来的是**事实**(脚本、标志文件、README 摘要),
|
|
19
|
+
// 忽略目录不进结果,坏掉的 package.json 不致命。
|
|
20
|
+
// 2. buildStartupPrompt —— 事实真的进了 prompt,脚本被截断时必须写明"只能推荐列出来的"。
|
|
21
|
+
// 3. validateSuggestions —— 这一层是**幻觉过滤器**:脚本名/目录不在扫描结果里的一律丢掉。
|
|
22
|
+
// 少了它,一句 `npm run dev:all` 就能变成界面上点了报错的按钮。
|
|
23
|
+
// 4. 路由出口 —— NO_MODEL 不联网;模型返回的一堆幻觉建议,过完路由后必须只剩合法的那条
|
|
24
|
+
// (证明过滤真的在请求链路上跑,而不只是单测里调了一次函数)。
|
|
25
|
+
import { test } from 'node:test'
|
|
26
|
+
import assert from 'node:assert/strict'
|
|
27
|
+
import fs from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import {
|
|
31
|
+
buildStartupPrompt,
|
|
32
|
+
collectProjectFacts,
|
|
33
|
+
detectPackageManager,
|
|
34
|
+
isRunnableShellCommand,
|
|
35
|
+
pickScripts,
|
|
36
|
+
registerProjectStartupAiRoutes,
|
|
37
|
+
validateSuggestions,
|
|
38
|
+
} from './projectStartupAi.js'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 铺一份 monorepo 夹具:根 + client + server,外加一个必须被忽略的 node_modules。
|
|
42
|
+
* 每个用例自己建一份 —— 共享目录会在并行跑测试时互相踩。
|
|
43
|
+
*/
|
|
44
|
+
function makeFixture() {
|
|
45
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zen-startup-ai-'))
|
|
46
|
+
const write = (rel, content) => {
|
|
47
|
+
const full = path.join(root, rel)
|
|
48
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
49
|
+
fs.writeFileSync(full, content, 'utf8')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
write('README.md', '# Demo Project\n\n## 启动方式\n\n先 `npm run dev:server`,再 `npm run dev:vue`。\n')
|
|
53
|
+
write('Dockerfile', 'FROM node:20\n')
|
|
54
|
+
write('pnpm-lock.yaml', 'lockfileVersion: 9\n')
|
|
55
|
+
write('package.json', JSON.stringify({
|
|
56
|
+
name: 'demo-root',
|
|
57
|
+
packageManager: 'pnpm@9.1.0',
|
|
58
|
+
workspaces: ['client', 'server'],
|
|
59
|
+
scripts: {
|
|
60
|
+
'dev': 'concurrently "npm:dev:server" "npm:dev:vue"',
|
|
61
|
+
'dev:server': 'node server/index.js',
|
|
62
|
+
'dev:vue': 'vite',
|
|
63
|
+
'verify:wb-card-hover-mask': 'node scripts/verify-hover.cjs',
|
|
64
|
+
'lint': 'eslint .',
|
|
65
|
+
},
|
|
66
|
+
devDependencies: { concurrently: '^9.0.0', vite: '^5.0.0' },
|
|
67
|
+
}, null, 2))
|
|
68
|
+
write('client/package.json', JSON.stringify({
|
|
69
|
+
name: 'demo-client',
|
|
70
|
+
scripts: { dev: 'vite', build: 'vite build' },
|
|
71
|
+
dependencies: { vue: '^3.5.0' },
|
|
72
|
+
}, null, 2))
|
|
73
|
+
write('server/package.json', JSON.stringify({
|
|
74
|
+
name: 'demo-server',
|
|
75
|
+
scripts: { start: 'node index.js' },
|
|
76
|
+
}, null, 2))
|
|
77
|
+
write('node_modules/left-pad/package.json', JSON.stringify({ name: 'left-pad', scripts: { dev: 'echo nope' } }))
|
|
78
|
+
write('client/node_modules/vite/package.json', JSON.stringify({ name: 'vite', scripts: { dev: 'echo nope' } }))
|
|
79
|
+
return root
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const factsOf = (root) => collectProjectFacts(root)
|
|
83
|
+
|
|
84
|
+
// ── collectProjectFacts ───────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
test('collectProjectFacts: 扫到脚本与标志文件,node_modules 不进结果', async () => {
|
|
87
|
+
const root = makeFixture()
|
|
88
|
+
const facts = await factsOf(root)
|
|
89
|
+
|
|
90
|
+
assert.equal(facts.projectName, 'demo-root')
|
|
91
|
+
assert.equal(facts.packageManager, 'pnpm') // packageManager 字段 + pnpm-lock 都指向 pnpm
|
|
92
|
+
assert.deepEqual(facts.dirs.map(d => d.relativePath), ['.', 'client', 'server'])
|
|
93
|
+
assert.ok(facts.dirs.every(d => !d.relativePath.includes('node_modules')))
|
|
94
|
+
|
|
95
|
+
const rootDir = facts.dirs[0]
|
|
96
|
+
assert.deepEqual(rootDir.pkg.scripts.dev, 'concurrently "npm:dev:server" "npm:dev:vue"')
|
|
97
|
+
assert.equal(rootDir.pkg.workspaces, true)
|
|
98
|
+
assert.ok(rootDir.markers.includes('Dockerfile'))
|
|
99
|
+
assert.ok(rootDir.markers.includes('pnpm-lock.yaml'))
|
|
100
|
+
assert.ok(facts.readmeHead.includes('启动方式')) // README 开头进了事实
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('collectProjectFacts: package.json 坏掉只丢这个包,不炸整个扫描', async () => {
|
|
104
|
+
const root = makeFixture()
|
|
105
|
+
fs.writeFileSync(path.join(root, 'client/package.json'), '{ this is not json', 'utf8')
|
|
106
|
+
const facts = await factsOf(root)
|
|
107
|
+
|
|
108
|
+
assert.equal(facts.dirs[0].pkg.name, 'demo-root') // 根还在
|
|
109
|
+
// 坏掉的包不进结果 —— 没有脚本可推荐,留着只会让模型对着一个空壳编脚本名
|
|
110
|
+
const clientDir = facts.dirs.find(d => d.relativePath === 'client')
|
|
111
|
+
assert.equal(clientDir, undefined)
|
|
112
|
+
assert.ok(facts.dirs.some(d => d.relativePath === 'server'), '后面还有目录,扫描没被中断')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ── 纯函数 ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
test('pickScripts: 名字像"启动"的排前面,超量截断', () => {
|
|
118
|
+
const picked = pickScripts({
|
|
119
|
+
'verify:wb-card-hover-mask': 'node x.cjs',
|
|
120
|
+
'zzz-unrelated': 'echo 1',
|
|
121
|
+
'dev:server': 'node server/index.js',
|
|
122
|
+
'dev': 'vite',
|
|
123
|
+
}, 2)
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(picked.map(([name]) => name), ['dev', 'dev:server'])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('detectPackageManager: 声明字段优先于锁文件,都没有就是 npm', () => {
|
|
129
|
+
assert.equal(detectPackageManager(['yarn.lock'], null), 'yarn')
|
|
130
|
+
assert.equal(detectPackageManager(['pnpm-lock.yaml'], { packageManager: 'yarn@4' }), 'yarn')
|
|
131
|
+
assert.equal(detectPackageManager([], null), 'npm')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('isRunnableShellCommand: 空 / 换行 / 超长一律不算能跑的命令', () => {
|
|
135
|
+
assert.equal(isRunnableShellCommand('docker compose up -d'), true)
|
|
136
|
+
assert.equal(isRunnableShellCommand(' '), false)
|
|
137
|
+
assert.equal(isRunnableShellCommand('npm run dev\nrm -rf /'), false)
|
|
138
|
+
assert.equal(isRunnableShellCommand('x'.repeat(401)), false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ── buildStartupPrompt ────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test('buildStartupPrompt: 脚本、标志文件、README 都进 prompt,中英各一套', async () => {
|
|
144
|
+
const facts = await factsOf(makeFixture())
|
|
145
|
+
const zh = buildStartupPrompt(facts, 'zh')
|
|
146
|
+
const en = buildStartupPrompt(facts, 'en')
|
|
147
|
+
|
|
148
|
+
assert.ok(zh.includes('dev = concurrently'))
|
|
149
|
+
assert.ok(zh.includes('标志文件:'))
|
|
150
|
+
assert.ok(zh.includes('Dockerfile'))
|
|
151
|
+
assert.ok(zh.includes('启动方式'))
|
|
152
|
+
assert.ok(zh.includes('只输出 JSON'))
|
|
153
|
+
assert.ok(!zh.includes('node_modules'))
|
|
154
|
+
|
|
155
|
+
assert.ok(en.includes('Output JSON only'))
|
|
156
|
+
assert.ok(en.includes('demo-client'))
|
|
157
|
+
assert.ok(!en.includes('只输出 JSON'))
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('buildStartupPrompt: 脚本被截断时必须告诉模型"只能推荐列出来的"', async () => {
|
|
161
|
+
const root = makeFixture()
|
|
162
|
+
const many = {}
|
|
163
|
+
for (let i = 0; i < 200; i++) many[`task-${String(i).padStart(3, '0')}`] = `node scripts/t${i}.cjs`
|
|
164
|
+
fs.writeFileSync(path.join(root, 'client/package.json'), JSON.stringify({ name: 'demo-client', scripts: many }), 'utf8')
|
|
165
|
+
|
|
166
|
+
const facts = await factsOf(root)
|
|
167
|
+
const zh = buildStartupPrompt(facts, 'zh')
|
|
168
|
+
|
|
169
|
+
assert.ok(zh.includes('未列出'), '截断必须写在 prompt 里,否则模型会推荐没列出来的脚本名')
|
|
170
|
+
assert.ok(zh.length < 60000, `prompt 不该被脚本撑爆,实际 ${zh.length} 字`)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ── validateSuggestions:幻觉过滤器 ─────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
const RAW_VALID = {
|
|
176
|
+
title: '同时起前后端',
|
|
177
|
+
kind: 'npm',
|
|
178
|
+
package: '.',
|
|
179
|
+
script: 'dev',
|
|
180
|
+
order: 1,
|
|
181
|
+
reason: '一条命令同时起后端和前端',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test('validateSuggestions: 合法的 npm 建议换成可直接执行的绝对路径', async () => {
|
|
185
|
+
const root = makeFixture()
|
|
186
|
+
const facts = await factsOf(root)
|
|
187
|
+
const [item] = validateSuggestions([RAW_VALID], facts, root, 'zh')
|
|
188
|
+
|
|
189
|
+
assert.equal(item.kind, 'npm')
|
|
190
|
+
assert.equal(item.scriptName, 'dev')
|
|
191
|
+
assert.equal(item.command, 'pnpm run dev') // 用项目自己的包管理器
|
|
192
|
+
assert.equal(item.packagePath, path.join(root, '.'))
|
|
193
|
+
assert.equal(item.order, 1)
|
|
194
|
+
assert.equal(item.reason, '一条命令同时起后端和前端')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('validateSuggestions: 编造的脚本名 / 包路径一律丢掉', async () => {
|
|
198
|
+
const root = makeFixture()
|
|
199
|
+
const facts = await factsOf(root)
|
|
200
|
+
const kept = validateSuggestions([
|
|
201
|
+
{ ...RAW_VALID, script: 'dev:all' }, // 根目录没有这条脚本
|
|
202
|
+
{ ...RAW_VALID, package: 'packages/client', script: 'dev' }, // 这个目录根本没扫到
|
|
203
|
+
{ ...RAW_VALID, package: 'client', script: 'build' }, // 这条是真的
|
|
204
|
+
], facts, root, 'zh')
|
|
205
|
+
|
|
206
|
+
assert.equal(kept.length, 1)
|
|
207
|
+
assert.equal(kept[0].packageLabel, 'client')
|
|
208
|
+
assert.equal(kept[0].scriptName, 'build')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('validateSuggestions: script 写成整条命令时能救回来,救不回就丢', async () => {
|
|
212
|
+
const root = makeFixture()
|
|
213
|
+
const facts = await factsOf(root)
|
|
214
|
+
const kept = validateSuggestions([
|
|
215
|
+
{ title: 'a', kind: 'npm', package: 'client', command: 'pnpm run build' }, // 从 command 里抠出 build
|
|
216
|
+
{ title: 'b', kind: 'npm', package: 'client', command: 'pnpm run build:all' }, // client 没有 build:all
|
|
217
|
+
{ title: 'c', kind: 'npm', package: 'client', command: 'vite --host' }, // 不是 run 形态,丢
|
|
218
|
+
], facts, root, 'zh')
|
|
219
|
+
|
|
220
|
+
assert.equal(kept.length, 1)
|
|
221
|
+
assert.equal(kept[0].scriptName, 'build')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('validateSuggestions: shell 命令只允许落在扫到过的目录里', async () => {
|
|
225
|
+
const root = makeFixture()
|
|
226
|
+
const facts = await factsOf(root)
|
|
227
|
+
const kept = validateSuggestions([
|
|
228
|
+
{ title: 'docker', kind: 'shell', command: 'docker compose up -d', cwd: '.', order: 1 },
|
|
229
|
+
{ title: '越界', kind: 'shell', command: 'ls', cwd: '../..' }, // 没扫到过的目录
|
|
230
|
+
{ title: '绝对路径', kind: 'shell', command: 'ls', cwd: 'C:\\Windows' },
|
|
231
|
+
{ title: '多行', kind: 'shell', command: 'npm i\nnpm run dev', cwd: '.' },
|
|
232
|
+
{ title: '子目录', kind: 'shell', command: 'node index.js', cwd: 'server', order: 2 },
|
|
233
|
+
], facts, root, 'zh')
|
|
234
|
+
|
|
235
|
+
assert.deepEqual(kept.map(k => k.command), ['docker compose up -d', 'node index.js'])
|
|
236
|
+
assert.equal(kept[1].cwd, path.join(root, 'server'))
|
|
237
|
+
assert.equal(kept[1].cwdLabel, 'server')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('validateSuggestions: 去重、按 order 排序、重编号并限量', async () => {
|
|
241
|
+
const root = makeFixture()
|
|
242
|
+
const facts = await factsOf(root)
|
|
243
|
+
const kept = validateSuggestions([
|
|
244
|
+
{ ...RAW_VALID, script: 'dev:vue', order: 3 },
|
|
245
|
+
{ ...RAW_VALID, script: 'dev', order: 2 },
|
|
246
|
+
{ ...RAW_VALID, script: 'dev:vue', order: 5 }, // 重复
|
|
247
|
+
{ ...RAW_VALID, script: 'not-exist', order: 0 },
|
|
248
|
+
{ ...RAW_VALID, package: 'client', script: 'dev', order: 10 },
|
|
249
|
+
], facts, root, 'zh')
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(kept.map(k => k.scriptName), ['dev', 'dev:vue', 'dev'])
|
|
252
|
+
assert.deepEqual(kept.map(k => k.order), [1, 2, 3]) // 编号连续,不受模型给的 order 影响
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('validateSuggestions: 不是数组 / 空对象都不该抛', async () => {
|
|
256
|
+
const root = makeFixture()
|
|
257
|
+
const facts = await factsOf(root)
|
|
258
|
+
assert.deepEqual(validateSuggestions(null, facts, root, 'zh'), [])
|
|
259
|
+
assert.deepEqual(validateSuggestions([null, 'x', 42, {}], facts, root, 'zh'), [])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// ── 路由 ──────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/** 只收 post 路由的假 app —— 与 recentDirectoriesAiSummary.test.js 同一套路,不起 express */
|
|
265
|
+
function createFakeApp() {
|
|
266
|
+
const routes = new Map()
|
|
267
|
+
return {
|
|
268
|
+
routes,
|
|
269
|
+
post(routePath, ...handlers) { routes.set(routePath, handlers.at(-1)) },
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function createFakeRes() {
|
|
274
|
+
const res = {
|
|
275
|
+
statusCode: 200,
|
|
276
|
+
body: null,
|
|
277
|
+
status(code) { res.statusCode = code; return res },
|
|
278
|
+
json(payload) { res.body = payload; return res },
|
|
279
|
+
}
|
|
280
|
+
return res
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 把 globalThis.fetch 换成吐固定 LLM 响应的桩;返回 [恢复函数, 收到的请求数组] */
|
|
284
|
+
function stubLlm(content) {
|
|
285
|
+
const original = globalThis.fetch
|
|
286
|
+
const calls = []
|
|
287
|
+
globalThis.fetch = async (url, init) => {
|
|
288
|
+
calls.push({ url: String(url), body: JSON.parse(init?.body || '{}'), headers: init?.headers || {} })
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
status: 200,
|
|
292
|
+
json: async () => ({ choices: [{ message: { content } }] }),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return [() => { globalThis.fetch = original }, calls]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const MODEL = { model: 'fixture-model', baseURL: 'https://api.example.com/v1', apiKey: 'k', isDefault: true }
|
|
299
|
+
|
|
300
|
+
async function runRoute({ models, root, locale = 'zh' }) {
|
|
301
|
+
const app = createFakeApp()
|
|
302
|
+
registerProjectStartupAiRoutes({
|
|
303
|
+
app,
|
|
304
|
+
configManager: { readRawConfigFile: async () => ({ models }) },
|
|
305
|
+
getCurrentProjectPath: () => root,
|
|
306
|
+
})
|
|
307
|
+
const res = createFakeRes()
|
|
308
|
+
await app.routes.get('/api/project-startup/suggestions')({ body: { locale } }, res)
|
|
309
|
+
return res
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
test('路由: 没配模型直接 NO_MODEL,一次网络请求都不发', async () => {
|
|
313
|
+
const root = makeFixture()
|
|
314
|
+
const [restore, calls] = stubLlm('{"suggestions":[]}')
|
|
315
|
+
try {
|
|
316
|
+
const res = await runRoute({ models: [], root })
|
|
317
|
+
assert.equal(res.statusCode, 200)
|
|
318
|
+
assert.equal(res.body.success, false)
|
|
319
|
+
assert.equal(res.body.code, 'NO_MODEL')
|
|
320
|
+
assert.equal(calls.length, 0)
|
|
321
|
+
} finally {
|
|
322
|
+
restore()
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('路由: 模型返回一堆幻觉,过完路由只剩那条真的', async () => {
|
|
327
|
+
const root = makeFixture()
|
|
328
|
+
const hallucinated = JSON.stringify({
|
|
329
|
+
suggestions: [
|
|
330
|
+
RAW_VALID,
|
|
331
|
+
{ title: '假脚本', kind: 'npm', package: '.', script: 'dev:all' },
|
|
332
|
+
{ title: '假目录', kind: 'npm', package: 'apps/web', script: 'dev' },
|
|
333
|
+
{ title: '越界 shell', kind: 'shell', command: 'rm -rf /', cwd: '../../..' },
|
|
334
|
+
{ title: '单起前端', kind: 'npm', package: 'client', script: 'dev', order: 2 },
|
|
335
|
+
],
|
|
336
|
+
})
|
|
337
|
+
const [restore, calls] = stubLlm(hallucinated)
|
|
338
|
+
try {
|
|
339
|
+
const res = await runRoute({ models: [MODEL], root })
|
|
340
|
+
|
|
341
|
+
assert.equal(res.body.success, true)
|
|
342
|
+
assert.deepEqual(
|
|
343
|
+
res.body.suggestions.map(s => `${s.packageLabel || s.cwdLabel}:${s.scriptName || s.command}`),
|
|
344
|
+
['.:dev', 'client:dev']
|
|
345
|
+
)
|
|
346
|
+
assert.equal(res.body.model, 'fixture-model')
|
|
347
|
+
// 事实真的进了 prompt —— 否则上面那两条"合法的"建议就只是碰巧
|
|
348
|
+
assert.ok(calls[0].body.messages.at(-1).content.includes('dev:server = node server/index.js'))
|
|
349
|
+
assert.equal(calls[0].body.response_format.type, 'json_object')
|
|
350
|
+
} finally {
|
|
351
|
+
restore()
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('路由: 目录里没有任何可扫的东西 → NO_FACTS,不白花一次模型钱', async () => {
|
|
356
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'zen-startup-ai-empty-'))
|
|
357
|
+
const [restore, calls] = stubLlm('{"suggestions":[]}')
|
|
358
|
+
try {
|
|
359
|
+
const res = await runRoute({ models: [MODEL], root: empty })
|
|
360
|
+
assert.equal(res.statusCode, 400)
|
|
361
|
+
assert.equal(res.body.code, 'NO_FACTS')
|
|
362
|
+
assert.equal(calls.length, 0)
|
|
363
|
+
} finally {
|
|
364
|
+
restore()
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('路由: 模型返回非 JSON → LLM_ERR(前端据此提示,而不是显示空列表)', async () => {
|
|
369
|
+
const root = makeFixture()
|
|
370
|
+
const [restore] = stubLlm('我觉得你这个项目应该用 npm run dev 启动')
|
|
371
|
+
try {
|
|
372
|
+
const res = await runRoute({ models: [MODEL], root })
|
|
373
|
+
assert.equal(res.statusCode, 500)
|
|
374
|
+
assert.equal(res.body.code, 'LLM_ERR')
|
|
375
|
+
} finally {
|
|
376
|
+
restore()
|
|
377
|
+
}
|
|
378
|
+
})
|
|
@@ -48,10 +48,10 @@ import {
|
|
|
48
48
|
nowIso,
|
|
49
49
|
genId,
|
|
50
50
|
} from './shared.js';
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// 100% 满格,而生成那一刻明明是当没给")
|
|
54
|
-
import { normalizePercent } from './progressReport.js';
|
|
51
|
+
// 百分比 / 静默时长的归一化只有一处实现(progressReport.js)—— 报告是它生成的,
|
|
52
|
+
// 读回来时也必须按同一把尺子收脏数据,两处各写一份必然分叉(分叉的表现是"盘上 130
|
|
53
|
+
// 被当成 100% 满格,而生成那一刻明明是当没给"、"盘上的没有静默被当成静默 0 秒")
|
|
54
|
+
import { normalizePercent, normalizeSilentMs } from './progressReport.js';
|
|
55
55
|
import { projectName, canonicalProjectPath } from './projectRegistry.js';
|
|
56
56
|
import { TARGET_SOURCES } from './targetResolver.js';
|
|
57
57
|
|
|
@@ -382,7 +382,7 @@ function normalizeReport(raw) {
|
|
|
382
382
|
// 不要硬塞一个默认值冒充"当时它就是在思考 / 当时静默了 0 秒"
|
|
383
383
|
toolMix: typeof t.toolMix === 'string' ? t.toolMix : '',
|
|
384
384
|
lastThought: typeof t.lastThought === 'string' ? t.lastThought : '',
|
|
385
|
-
silentMs:
|
|
385
|
+
silentMs: normalizeSilentMs(t.silentMs),
|
|
386
386
|
// 每个任务各自的进度。与整体那条同一个口径(null = 模型没给),
|
|
387
387
|
// 老记录里没有它 —— 一律 null,不拿整体百分比摊到每个任务头上
|
|
388
388
|
percent: normalizePercent(t.percent),
|
|
@@ -248,3 +248,30 @@ test('老记录没有 jobId 时是 null(不是 undefined —— 前端两种
|
|
|
248
248
|
assert.ok('jobId' in r.tasks[0], '字段本身必须在');
|
|
249
249
|
assert.equal(r.tasks[0].jobId, null);
|
|
250
250
|
});
|
|
251
|
+
|
|
252
|
+
// ── 静默时长 ─────────────────────────────────────────────────────────────
|
|
253
|
+
//
|
|
254
|
+
// 「静默 x 秒」是"它可能卡住了"的告警信号,只有真静默过才配画。
|
|
255
|
+
// 归一这步以前把"没有静默"(silentMsOf 给的 null)写成了 0 —— `Number(null)` 是 0 ——
|
|
256
|
+
// 面板读盘上历史报告时照着画,事实卡上就多出一条「静默 0 秒」(用户 2026-10-04 反馈)。
|
|
257
|
+
|
|
258
|
+
test('没有静默这件事时是 null / 0,不是"静默 0 秒"', async () => {
|
|
259
|
+
await seed([rawReport({
|
|
260
|
+
id: 'silent-cases',
|
|
261
|
+
tasks: [
|
|
262
|
+
{ ...fact('t1', '显式 null'), silentMs: null },
|
|
263
|
+
{ ...fact('t2', '老记录的 0'), silentMs: 0 },
|
|
264
|
+
{ ...fact('t3', '空串'), silentMs: '' },
|
|
265
|
+
{ ...fact('t4', '缺字段'), silentMs: undefined },
|
|
266
|
+
{ ...fact('t5', '负数'), silentMs: -1 },
|
|
267
|
+
{ ...fact('t6', '真静默'), silentMs: 6 * 60 * 1000 },
|
|
268
|
+
{ ...fact('t7', '带零头的真静默'), silentMs: 90_000.7 },
|
|
269
|
+
],
|
|
270
|
+
})]);
|
|
271
|
+
const [r] = await readReports();
|
|
272
|
+
assert.deepEqual(
|
|
273
|
+
r.tasks.map(t => t.silentMs),
|
|
274
|
+
[null, null, null, null, null, 6 * 60 * 1000, 90_000],
|
|
275
|
+
'只有正数的静默时长该留下来(毫秒取整)',
|
|
276
|
+
);
|
|
277
|
+
});
|
|
@@ -217,6 +217,21 @@ export function normalizePercent(value) {
|
|
|
217
217
|
return round >= 0 && round <= 100 ? round : null;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* 静默时长归一:正整数毫秒,其余(缺字段 / 脏数据 / 0)一律 null。
|
|
222
|
+
*
|
|
223
|
+
* 与 normalizePercent 同一个坑:`Number(null)` 与 `Number('')` 都是 0,直接
|
|
224
|
+
* `Number.isFinite(Number(v))` 会把"没有静默这个事实"(silentMsOf 给的 null)
|
|
225
|
+
* 写成 0,面板读盘上历史报告时就画出一个「静默 0 秒」的标签 —— 那是"它刚说过话",
|
|
226
|
+
* 不是"它可能卡住了"(用户 2026-10-04 反馈)。
|
|
227
|
+
*/
|
|
228
|
+
export function normalizeSilentMs(value) {
|
|
229
|
+
if (value === null || value === undefined || value === '') return null;
|
|
230
|
+
const n = Number(value);
|
|
231
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
232
|
+
return Math.floor(n);
|
|
233
|
+
}
|
|
234
|
+
|
|
220
235
|
/** 从一段文本里取第一个整数当百分比(`62%` / `62 分` / `大概 62` 都认) */
|
|
221
236
|
function percentFromText(value) {
|
|
222
237
|
const m = String(value == null ? '' : value).match(/\d+/);
|