dsh-card-dispatch 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +249 -2
- package/cordis.patch.yml +25 -0
- package/icon.svg +21 -0
- package/index.js +88 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/src/catalog-source.js +122 -0
- package/src/client/index.js +155 -0
- package/src/config-store.js +49 -0
- package/src/config.js +229 -0
- package/src/console.html +964 -0
- package/src/criteria-llm.js +79 -0
- package/src/criteria.js +103 -0
- package/src/gates.js +123 -0
- package/src/human-channel.js +794 -0
- package/src/import.js +193 -0
- package/src/model-call.js +89 -0
- package/src/models.js +249 -0
- package/src/parse-llm.js +96 -0
- package/src/prompts-llm.js +104 -0
- package/src/selfcheck.js +117 -0
- package/src/split.js +397 -0
- package/src/verdict.js +133 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* human-channel:控制台页面 + 拆分接口(**只剩这些**)。
|
|
3
|
+
*
|
|
4
|
+
* 定位(偏差 D-15):本插件不再开会话、不再派发、不再记台账——
|
|
5
|
+
* 执行全部交给 `@linxin666/dsh-client-ui-task-board`。这里只提供:
|
|
6
|
+
*
|
|
7
|
+
* GET / 控制台页面(每次请求从磁盘读,改页面不用重启)
|
|
8
|
+
* GET /view 页面要的快照:版本号 + 可用路由 + 看板基址
|
|
9
|
+
* POST /split 需求 → 看板卡片 + action 信封(纯计算,不落盘)
|
|
10
|
+
*
|
|
11
|
+
* 两道门(沿用旧实现,仍然值得保留):
|
|
12
|
+
* 1. **本地回环**:只看 `req.socket.remoteAddress`,非回环一律 403;
|
|
13
|
+
* 宿主若有 `connection` 服务,再叠一次它的 Host/Origin 栅栏。
|
|
14
|
+
* 2. **人操作标记**:POST 必须带 `x-card-dispatch-human: true` 或 body `human: true`。
|
|
15
|
+
* 这不是鉴权(本机人类就是权威),而是把「人手点的」和「脚本误发」分开。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
import { splitRequirement } from './split.js'
|
|
23
|
+
import { describeConfig, mergeConfig, normalizeConfig } from './config.js'
|
|
24
|
+
import { evaluateStart } from './gates.js'
|
|
25
|
+
import { runSelfCheck } from './selfcheck.js'
|
|
26
|
+
import { decideNext, parseVerdict, verdictPath } from './verdict.js'
|
|
27
|
+
import { parseRequirementDoc } from './import.js'
|
|
28
|
+
import { suggestCriteria } from './criteria.js'
|
|
29
|
+
import { draftWithModel } from './criteria-llm.js'
|
|
30
|
+
import { PARSE_SYSTEM, parseRequirementWithModel } from './parse-llm.js'
|
|
31
|
+
import { PROMPTS_SYSTEM, writeCardIntros } from './prompts-llm.js'
|
|
32
|
+
import { outlineCards } from './split.js'
|
|
33
|
+
import { listRoutes, pickRoute, splitRoute } from './models.js'
|
|
34
|
+
|
|
35
|
+
export const HUMAN_ROUTE_PREFIX = '/api/card-dispatch'
|
|
36
|
+
export const HUMAN_HEADER = 'x-card-dispatch-human'
|
|
37
|
+
|
|
38
|
+
/** 请求体上限:需求文本再长也用不了这么多,超了直接 413。 */
|
|
39
|
+
const BODY_LIMIT = 256 * 1024
|
|
40
|
+
|
|
41
|
+
/** 页面占位符:服务端渲染时替换成真实哈希,页面拿它和自己比,不一致就整页 reload。 */
|
|
42
|
+
const PAGE_VERSION_PLACEHOLDER = '__CARD_DISPATCH_PAGE_VERSION__'
|
|
43
|
+
|
|
44
|
+
const CONSOLE_FILE = new URL('./console.html', import.meta.url)
|
|
45
|
+
|
|
46
|
+
function isLoopbackAddress(address) {
|
|
47
|
+
const value = String(address ?? '')
|
|
48
|
+
return value === '::1' || value === '127.0.0.1' || value.startsWith('127.') || value === '::ffff:127.0.0.1'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 模型写的「提示词开头」,只认 root / part-N 两种 key。
|
|
53
|
+
*
|
|
54
|
+
* /split 是能直接 curl 的,所以这些从页面回传的文本要在这里再收一遍:
|
|
55
|
+
* 只收字符串、只收认得的键、限长。
|
|
56
|
+
*/
|
|
57
|
+
function sanitizeIntros(input) {
|
|
58
|
+
if (input === null || input === undefined || typeof input !== 'object') return {}
|
|
59
|
+
const out = {}
|
|
60
|
+
for (const [key, value] of Object.entries(input)) {
|
|
61
|
+
if (!/^(root|part-\d{1,2})$/.test(key)) continue
|
|
62
|
+
if (typeof value !== 'string') continue
|
|
63
|
+
const text = value.trim()
|
|
64
|
+
if (text === '') continue
|
|
65
|
+
out[key] = text.slice(0, 1200)
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 读页面 + 算版本号。每次请求都读:改 HTML 不该要求重启宿主。 */function readConsole() {
|
|
71
|
+
const html = readFileSync(CONSOLE_FILE, 'utf8')
|
|
72
|
+
const version = createHash('sha256').update(html).digest('hex').slice(0, 12)
|
|
73
|
+
return { html: html.replace(PAGE_VERSION_PLACEHOLDER, version), version }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readBoundedBody(req) {
|
|
77
|
+
const chunks = []
|
|
78
|
+
let size = 0
|
|
79
|
+
for await (const chunk of req) {
|
|
80
|
+
size += chunk.length
|
|
81
|
+
if (size > BODY_LIMIT) {
|
|
82
|
+
const error = new Error('请求体过大')
|
|
83
|
+
error.code = 'E_BODY_TOO_LARGE'
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
chunks.push(chunk)
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 只读快照。字段刻意很少:页面需要知道「宿主是不是旧代码」和「看板挂在哪」。
|
|
93
|
+
*
|
|
94
|
+
* @param {object} options
|
|
95
|
+
* @param {string} options.version 当前页面哈希
|
|
96
|
+
* @returns {object}
|
|
97
|
+
*/
|
|
98
|
+
function snapshot({ version, workspaces }) {
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
pageVersion: version,
|
|
102
|
+
// 工作区列表:卡片不指定 workspaceId 时,看板会回退到「最近改动过的那个工作区」,
|
|
103
|
+
// 对自动跑卡来说是隐患(会随人切会话而变)。所以把列表给页面,让人显式选。
|
|
104
|
+
workspaces,
|
|
105
|
+
// 页面据此判断自己是不是旧文档(旧文档会调不存在的路由,看着像功能没做)
|
|
106
|
+
capabilities: ['view', 'split', 'status', 'config', 'rework', 'import', 'criteria', 'models', 'parse', 'plan'],
|
|
107
|
+
// 看板路由挂在同一个宿主上,页面自己拼基址;这里给出来只是方便排障
|
|
108
|
+
board: { prefix: '/api/task-board' },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 路由表。刻意做成纯函数集合,测试可以直接喂 `{method, pathname, body, headers}`。
|
|
114
|
+
*
|
|
115
|
+
* @param {object} [deps]
|
|
116
|
+
* @returns {{handle: (request: object) => Promise<{status:number, body:any, content?:string}>}}
|
|
117
|
+
*/
|
|
118
|
+
export function createHumanChannel(deps = {}) {
|
|
119
|
+
const marked = (body, headers) => headers?.[HUMAN_HEADER] === 'true' || body?.human === true
|
|
120
|
+
// 惰性:workspaceRegistry 可能比本插件晚注册;拿不到就返回空列表,页面退化成「默认」
|
|
121
|
+
const listWorkspaces = typeof deps.workspaces === 'function' ? deps.workspaces : () => []
|
|
122
|
+
const safeWorkspaces = () => {
|
|
123
|
+
try {
|
|
124
|
+
const items = listWorkspaces()
|
|
125
|
+
return Array.isArray(items) ? items : []
|
|
126
|
+
} catch {
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 运行期配置:面板改完立刻生效,不需要重启宿主。 */
|
|
132
|
+
let current = deps.config ?? normalizeConfig({})
|
|
133
|
+
const store = deps.store ?? null
|
|
134
|
+
const facts = typeof deps.facts === 'function' ? deps.facts : () => ({})
|
|
135
|
+
/* 模型接口:llm 是宿主的官方服务,catalog 是「设置里已添加的模型」。
|
|
136
|
+
两者都可能拿不到(旧宿主、没有 provider),所以每次调用都重新取并容错。 */
|
|
137
|
+
const modelDeps = deps.models ?? {}
|
|
138
|
+
const getLlm = () => {
|
|
139
|
+
try {
|
|
140
|
+
return typeof modelDeps.llm === 'function' ? modelDeps.llm() : null
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const loadCatalogSafe = async () => {
|
|
146
|
+
try {
|
|
147
|
+
const result = typeof modelDeps.catalog === 'function' ? await modelDeps.catalog() : null
|
|
148
|
+
return result !== null && typeof result === 'object'
|
|
149
|
+
? result
|
|
150
|
+
: { file: null, catalog: { default: null, groups: [] }, error: '宿主没有提供模型清单' }
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { file: null, catalog: { default: null, groups: [] }, error: String(error?.message ?? error) }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 改配置。只接受声明表里出现过的路径(`gates.budgetPerDay` 这种扁平写法),
|
|
158
|
+
* 未知路径原样回给页面——人以为改了其实没改,比报错更糟。
|
|
159
|
+
*/
|
|
160
|
+
const configRoute = async ({ body, headers }) => {
|
|
161
|
+
if (!marked(body, headers)) {
|
|
162
|
+
return {
|
|
163
|
+
status: 400,
|
|
164
|
+
body: {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: { code: 'E_SCHEMA_INVALID', message: `改配置也要人标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true` },
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const patch = body?.patch
|
|
171
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
172
|
+
return {
|
|
173
|
+
status: 400,
|
|
174
|
+
body: { ok: false, error: { code: 'E_SCHEMA_INVALID', message: 'patch 必须是「路径 → 值」的扁平对象' } },
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const { config: merged, rejected } = mergeConfig(current, patch)
|
|
178
|
+
current = merged
|
|
179
|
+
try {
|
|
180
|
+
store?.save(merged)
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
status: 500,
|
|
184
|
+
body: { ok: false, error: { code: 'E_STORE_WRITE', message: `配置写盘失败:${error?.message ?? String(error)}` } },
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { status: 200, body: { ok: true, rejected, config: describeConfig(merged) } }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const statusRoute = () => {
|
|
191
|
+
const workspaces = safeWorkspaces()
|
|
192
|
+
const check = runSelfCheck({ config: current, facts: { ...facts(), workspaceCount: workspaces.length } })
|
|
193
|
+
return {
|
|
194
|
+
status: 200,
|
|
195
|
+
body: {
|
|
196
|
+
ok: true,
|
|
197
|
+
enabled: current.enabled === true,
|
|
198
|
+
permission: current.permission,
|
|
199
|
+
configFile: store?.file ?? null,
|
|
200
|
+
config: describeConfig(current),
|
|
201
|
+
selfCheck: check,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 「检查并返工」:人点一下(或命令行敲一下)才跑——不轮询、不起定时器。
|
|
208
|
+
*
|
|
209
|
+
* 判定材料是验收卡落盘的 `verdict.json`:插件读得到**文件**,读不到**看板状态**
|
|
210
|
+
* (`/api/task-board/state` 走看板自己的信任栅栏,实测 403)。这也是为什么结论
|
|
211
|
+
* 必须落盘:落盘的才存在(基线 P3),才轮得到第三方复核。
|
|
212
|
+
*/
|
|
213
|
+
const reworkRoute = async ({ body, headers }) => {
|
|
214
|
+
if (!marked(body, headers)) {
|
|
215
|
+
return {
|
|
216
|
+
status: 400,
|
|
217
|
+
body: {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: { code: 'E_SCHEMA_INVALID', message: `触发返工也要人标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true` },
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const rootId = String(body?.rootId ?? '').trim()
|
|
224
|
+
const workspaceRoot = String(body?.workspaceRoot ?? '').trim()
|
|
225
|
+
if (!/^[0-9a-fA-F-]{36}$/.test(rootId)) {
|
|
226
|
+
return { status: 400, body: { ok: false, error: { code: 'E_SCHEMA_INVALID', message: 'rootId 必须是拆分时返回的父卡 id' } } }
|
|
227
|
+
}
|
|
228
|
+
if (workspaceRoot === '') {
|
|
229
|
+
return {
|
|
230
|
+
status: 400,
|
|
231
|
+
body: { ok: false, error: { code: 'E_SCHEMA_INVALID', message: '需要 workspaceRoot(工作区根目录),否则不知道去哪读验收结论' } },
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const path = join(workspaceRoot, verdictPath(rootId))
|
|
236
|
+
let text = null
|
|
237
|
+
let readError = null
|
|
238
|
+
try {
|
|
239
|
+
text = readFileSync(path, 'utf8')
|
|
240
|
+
} catch (error) {
|
|
241
|
+
readError =
|
|
242
|
+
error?.code === 'ENOENT'
|
|
243
|
+
? `还没有验收结论:${path} 不存在(验收卡可能还没跑)`
|
|
244
|
+
: `读取验收结论失败:${error?.message ?? String(error)}`
|
|
245
|
+
}
|
|
246
|
+
const parsed =
|
|
247
|
+
text === null
|
|
248
|
+
? { ok: false, verdict: null, items: [], problems: [], summary: '', error: readError }
|
|
249
|
+
: parseVerdict(text)
|
|
250
|
+
|
|
251
|
+
const decision = decideNext({ parsed, config: current, rejectedCount: body?.rejectedCount ?? 0 })
|
|
252
|
+
// 一次可以重跑多张卡:验收结论的 items 里可能有好几条判据,分属不同子卡。
|
|
253
|
+
// `taskId` 是单卡的老写法,保留;`taskIds` 是页面上用的写法。
|
|
254
|
+
const requested = Array.isArray(body?.taskIds)
|
|
255
|
+
? body.taskIds
|
|
256
|
+
: (typeof body?.taskId === 'string' ? [body.taskId] : [])
|
|
257
|
+
const taskIds = requested.map((value) => String(value ?? '').trim()).filter((value) => value !== '')
|
|
258
|
+
const runs = decision.action === 'rework'
|
|
259
|
+
? taskIds.map((id) => ({ requestId: `card-dispatch-${randomUUID()}`, action: { kind: 'run', taskId: id } }))
|
|
260
|
+
: []
|
|
261
|
+
return {
|
|
262
|
+
status: 200,
|
|
263
|
+
body: {
|
|
264
|
+
ok: true,
|
|
265
|
+
path,
|
|
266
|
+
verdict: parsed,
|
|
267
|
+
decision,
|
|
268
|
+
runs,
|
|
269
|
+
run: runs.length > 0 ? runs[0] : null,
|
|
270
|
+
// 判定做完了但不知道重跑哪张卡时,明确说清楚
|
|
271
|
+
hint: decision.action === 'rework' && runs.length === 0 ? '要重跑就带上 taskIds(被拒的那几张开发卡)' : '',
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 从文档导入需求。
|
|
278
|
+
*
|
|
279
|
+
* 文件由浏览器读成文本再发过来——插件不碰用户的磁盘路径,也就没有「读哪个目录」的权限问题。
|
|
280
|
+
* 解析永远返回 200:`ok:false` 表示「读到了但抽不出东西」,警告原文给页面显示,
|
|
281
|
+
* 比一个 400 更接近事实(文件没坏,只是格式不支持或没写判据)。
|
|
282
|
+
*/
|
|
283
|
+
const importRoute = async ({ body, headers }) => {
|
|
284
|
+
if (!marked(body, headers)) {
|
|
285
|
+
return {
|
|
286
|
+
status: 400,
|
|
287
|
+
body: {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: { code: 'E_SCHEMA_INVALID', message: `导入也要人标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true` },
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const text = typeof body?.text === 'string' ? body.text : ''
|
|
294
|
+
if (text.trim() === '') {
|
|
295
|
+
return {
|
|
296
|
+
status: 400,
|
|
297
|
+
body: { ok: false, error: { code: 'E_SCHEMA_INVALID', message: '没有收到文档正文(text 为空)' } },
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const result = parseRequirementDoc({ filename: String(body?.filename ?? ''), text })
|
|
301
|
+
return { status: 200, body: { ok: result.ok, ...result } }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 模型清单:页面下拉用。读的是宿主设置里已添加的模型,这里只读不写。 */
|
|
305
|
+
const modelsRoute = async () => {
|
|
306
|
+
const loaded = await loadCatalogSafe()
|
|
307
|
+
return {
|
|
308
|
+
status: 200,
|
|
309
|
+
body: {
|
|
310
|
+
ok: true,
|
|
311
|
+
file: loaded.file,
|
|
312
|
+
error: loaded.error,
|
|
313
|
+
default: loaded.catalog.default,
|
|
314
|
+
configured: current.models.draftRoute,
|
|
315
|
+
effective: pickRoute(current.models.draftRoute, loaded.catalog),
|
|
316
|
+
// 页面要知道这个"生效中的模型"是配置里存的、还是宿主的默认值(决定提示怎么写)。
|
|
317
|
+
source: current.models.draftRoute.trim() === '' ? 'host' : 'config',
|
|
318
|
+
options: listRoutes(loaded.catalog),
|
|
319
|
+
enabled: current.models.useModel,
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 判据草稿:**先用模型**(宿主已添加的模型),失败或关掉时退回规则提炼。
|
|
326
|
+
*
|
|
327
|
+
* 一次点击 = 一次很小的模型调用,会计费,所以响应里把用的模型写清楚,
|
|
328
|
+
* 页面也会显示。模型与密钥都由宿主管,插件不碰。
|
|
329
|
+
*/
|
|
330
|
+
const criteriaRoute = async ({ body, headers }) => {
|
|
331
|
+
if (!marked(body, headers)) {
|
|
332
|
+
return {
|
|
333
|
+
status: 400,
|
|
334
|
+
body: {
|
|
335
|
+
ok: false,
|
|
336
|
+
error: { code: 'E_SCHEMA_INVALID', message: `生成草稿也要人标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true` },
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const title = String(body?.title ?? '')
|
|
341
|
+
const description = String(body?.description ?? '')
|
|
342
|
+
const requested = String(body?.model ?? '').trim()
|
|
343
|
+
const notes = []
|
|
344
|
+
|
|
345
|
+
if (!current.models.useModel) notes.push('模型生成是关着的(开关里可开),下面是规则提炼的草稿。')
|
|
346
|
+
|
|
347
|
+
if (current.models.useModel) {
|
|
348
|
+
const loaded = await loadCatalogSafe()
|
|
349
|
+
const routeText = pickRoute(requested !== '' ? requested : current.models.draftRoute, loaded.catalog)
|
|
350
|
+
const route = routeText === null ? null : splitRoute(routeText)
|
|
351
|
+
const llm = getLlm()
|
|
352
|
+
if (route === null) {
|
|
353
|
+
notes.push('没有可用的模型(设置里还没添加?),下面是规则提炼的草稿。')
|
|
354
|
+
} else if (llm === null) {
|
|
355
|
+
notes.push('宿主没提供模型接口,下面是规则提炼的草稿。')
|
|
356
|
+
} else {
|
|
357
|
+
const drafted = await draftWithModel({ llm, route, title, description })
|
|
358
|
+
if (drafted.ok) {
|
|
359
|
+
return {
|
|
360
|
+
status: 200,
|
|
361
|
+
body: { ok: true, source: 'model', model: routeText, criteria: drafted.criteria, notes: [`由 ${routeText} 生成,改完再用。`] },
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
notes.push(`模型没用上(${drafted.error}),下面是规则提炼的草稿。`)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const fallback = suggestCriteria({ title, description })
|
|
369
|
+
return {
|
|
370
|
+
status: 200,
|
|
371
|
+
body: { ok: true, source: 'rules', model: null, criteria: fallback.criteria, notes: [...notes, ...fallback.notes] },
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* AI 解析需求:把粘贴的一段原文整理成「标题 + 描述 + 判据」。
|
|
377
|
+
*
|
|
378
|
+
* 模型可用就用模型;不可用就退回文档解析(同一套规则:认标题、认判据列表),
|
|
379
|
+
* 并在 notes 里说清是哪条路——绝不假装模型读过。
|
|
380
|
+
* `prompt` 一并回给页面,人可以点开看模型到底被告知了什么。
|
|
381
|
+
*/
|
|
382
|
+
const parseRoute = async ({ body, headers }) => {
|
|
383
|
+
if (!marked(body, headers)) {
|
|
384
|
+
return {
|
|
385
|
+
status: 400,
|
|
386
|
+
body: {
|
|
387
|
+
ok: false,
|
|
388
|
+
error: { code: 'E_SCHEMA_INVALID', message: `解析也要人标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true` },
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const text = String(body?.text ?? '').trim()
|
|
393
|
+
if (text === '') {
|
|
394
|
+
return { status: 400, body: { ok: false, error: { code: 'E_EMPTY_TEXT', message: '没有可解析的原文' } } }
|
|
395
|
+
}
|
|
396
|
+
const notes = []
|
|
397
|
+
const requested = String(body?.model ?? '').trim()
|
|
398
|
+
|
|
399
|
+
if (current.models.useModel) {
|
|
400
|
+
const loaded = await loadCatalogSafe()
|
|
401
|
+
const routeText = pickRoute(requested !== '' ? requested : current.models.draftRoute, loaded.catalog)
|
|
402
|
+
const route = routeText === null ? null : splitRoute(routeText)
|
|
403
|
+
const llm = getLlm()
|
|
404
|
+
if (route === null) {
|
|
405
|
+
notes.push('没有可用的模型,下面是按格式规则解析的结果。')
|
|
406
|
+
} else if (llm === null) {
|
|
407
|
+
notes.push('宿主没提供模型接口,下面是按格式规则解析的结果。')
|
|
408
|
+
} else {
|
|
409
|
+
const parsed = await parseRequirementWithModel({ llm, route, text })
|
|
410
|
+
if (parsed.ok) {
|
|
411
|
+
return {
|
|
412
|
+
status: 200,
|
|
413
|
+
body: {
|
|
414
|
+
ok: true,
|
|
415
|
+
source: 'model',
|
|
416
|
+
model: routeText,
|
|
417
|
+
title: parsed.title,
|
|
418
|
+
description: parsed.description,
|
|
419
|
+
criteria: parsed.criteria,
|
|
420
|
+
notes: [`由 ${routeText} 解析,改完再用。`],
|
|
421
|
+
prompt: PARSE_SYSTEM,
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
notes.push(`模型没用上(${parsed.error}),下面是按格式规则解析的结果。`)
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
notes.push('模型解析是关着的(开关里可开),下面是按格式规则解析的结果。')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 走文档解析时借一个 .md 文件名:解析器按扩展名判格式,粘贴的原文没有文件名。
|
|
432
|
+
const fallback = parseRequirementDoc({ filename: '粘贴的需求.md', text })
|
|
433
|
+
if (!fallback.ok) {
|
|
434
|
+
return {
|
|
435
|
+
status: 200,
|
|
436
|
+
body: {
|
|
437
|
+
ok: false,
|
|
438
|
+
source: 'rules',
|
|
439
|
+
model: null,
|
|
440
|
+
title: '',
|
|
441
|
+
description: '',
|
|
442
|
+
criteria: [],
|
|
443
|
+
notes: [...notes, ...fallback.warnings],
|
|
444
|
+
prompt: PARSE_SYSTEM,
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
status: 200,
|
|
450
|
+
body: {
|
|
451
|
+
ok: true,
|
|
452
|
+
source: 'rules',
|
|
453
|
+
model: null,
|
|
454
|
+
title: fallback.title,
|
|
455
|
+
description: fallback.description,
|
|
456
|
+
criteria: fallback.criteria,
|
|
457
|
+
notes: [...notes, ...fallback.warnings],
|
|
458
|
+
prompt: PARSE_SYSTEM,
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* AI 解析并拆分:一次点击走完
|
|
465
|
+
* 原文 →(模型)标题 / 描述 / 判据 →(规则)拆成哪几张卡 →(模型)每张卡的提示词开头。
|
|
466
|
+
*
|
|
467
|
+
* 结构仍由规则决定(拆几张、哪几条判据一组),模型只负责措辞——这样账单可预期,
|
|
468
|
+
* 「完成要交证据」那些硬要求也仍然由 split.js 确定性拼上。
|
|
469
|
+
* 返回的 intros 由页面回传给 /split,写卡时与预览用的是同一份。
|
|
470
|
+
*/
|
|
471
|
+
const planRoute = async ({ body, headers }) => {
|
|
472
|
+
if (!marked(body, headers)) {
|
|
473
|
+
return {
|
|
474
|
+
status: 400,
|
|
475
|
+
body: {
|
|
476
|
+
ok: false,
|
|
477
|
+
error: { code: 'E_SCHEMA_INVALID', message: `解析也要人标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true` },
|
|
478
|
+
},
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const text = String(body?.text ?? '').trim()
|
|
482
|
+
if (text === '') {
|
|
483
|
+
return { status: 400, body: { ok: false, error: { code: 'E_EMPTY_TEXT', message: '没有可解析的原文' } } }
|
|
484
|
+
}
|
|
485
|
+
const notes = []
|
|
486
|
+
const requested = String(body?.model ?? '').trim()
|
|
487
|
+
const parallel = body?.parallel !== false
|
|
488
|
+
|
|
489
|
+
let title = ''
|
|
490
|
+
let description = ''
|
|
491
|
+
let criteria = []
|
|
492
|
+
let source = 'rules'
|
|
493
|
+
let modelUsed = null
|
|
494
|
+
let llm = null
|
|
495
|
+
let route = null
|
|
496
|
+
let routeText = null
|
|
497
|
+
|
|
498
|
+
if (current.models.useModel) {
|
|
499
|
+
const loaded = await loadCatalogSafe()
|
|
500
|
+
routeText = pickRoute(requested !== '' ? requested : current.models.draftRoute, loaded.catalog)
|
|
501
|
+
route = routeText === null ? null : splitRoute(routeText)
|
|
502
|
+
llm = getLlm()
|
|
503
|
+
if (route === null) notes.push('没有可用的模型(设置里还没添加?),按格式规则解析。')
|
|
504
|
+
else if (llm === null) notes.push('宿主没提供模型接口,按格式规则解析。')
|
|
505
|
+
else {
|
|
506
|
+
const parsed = await parseRequirementWithModel({ llm, route, text })
|
|
507
|
+
if (parsed.ok) {
|
|
508
|
+
title = parsed.title
|
|
509
|
+
description = parsed.description
|
|
510
|
+
criteria = parsed.criteria
|
|
511
|
+
source = 'model'
|
|
512
|
+
modelUsed = routeText
|
|
513
|
+
notes.push(`标题 / 描述 / 判据由 ${routeText} 整理。`)
|
|
514
|
+
} else {
|
|
515
|
+
notes.push(`解析没用上模型(${parsed.error}),按格式规则解析。`)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
notes.push('模型解析是关着的(开关里可开),按格式规则解析。')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (source !== 'model') {
|
|
523
|
+
// 借一个 .md 文件名:解析器按扩展名判格式,粘贴的原文没有文件名。
|
|
524
|
+
const fallback = parseRequirementDoc({ filename: '粘贴的需求.md', text })
|
|
525
|
+
if (!fallback.ok) {
|
|
526
|
+
return {
|
|
527
|
+
status: 200,
|
|
528
|
+
body: { ok: false, source, model: modelUsed, title: '', description: '', criteria: [], intros: {}, notes: [...notes, ...fallback.warnings] },
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
title = fallback.title
|
|
532
|
+
description = fallback.description
|
|
533
|
+
criteria = fallback.criteria
|
|
534
|
+
notes.push(...fallback.warnings)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const outline = outlineCards({
|
|
538
|
+
title,
|
|
539
|
+
criteria,
|
|
540
|
+
parallel,
|
|
541
|
+
requireEvidence: current.gates.requireEvidence === true,
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
let intros = {}
|
|
545
|
+
if (llm !== null && route !== null) {
|
|
546
|
+
const written = await writeCardIntros({
|
|
547
|
+
llm,
|
|
548
|
+
route,
|
|
549
|
+
requirement: [title, description].filter((line) => line !== '').join('\n'),
|
|
550
|
+
cards: outline,
|
|
551
|
+
})
|
|
552
|
+
if (written.ok) {
|
|
553
|
+
intros = written.intros
|
|
554
|
+
notes.push(`每张卡的提示词由 ${routeText} 按需求写(结构仍按规则拆)。`)
|
|
555
|
+
if (written.error !== null) notes.push(written.error)
|
|
556
|
+
} else {
|
|
557
|
+
notes.push(`提示词没写成(${written.error}),用模板拼。`)
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
notes.push('没有可用模型,每张卡的提示词用模板拼。')
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
status: 200,
|
|
565
|
+
body: {
|
|
566
|
+
ok: true,
|
|
567
|
+
source,
|
|
568
|
+
model: modelUsed,
|
|
569
|
+
title,
|
|
570
|
+
description,
|
|
571
|
+
criteria,
|
|
572
|
+
outline,
|
|
573
|
+
intros,
|
|
574
|
+
notes,
|
|
575
|
+
// 让人能核对模型到底被告知了什么:两段提示词都回给页面。
|
|
576
|
+
prompts: { parse: PARSE_SYSTEM, cardIntros: PROMPTS_SYSTEM },
|
|
577
|
+
},
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const splitRouteHandler = async ({ body, headers }) => {
|
|
582
|
+
if (!marked(body, headers)) {
|
|
583
|
+
return {
|
|
584
|
+
status: 400,
|
|
585
|
+
body: {
|
|
586
|
+
ok: false,
|
|
587
|
+
error: {
|
|
588
|
+
code: 'E_SCHEMA_INVALID',
|
|
589
|
+
message: `写看板的请求需要明确标记:请求头 ${HUMAN_HEADER}: true 或 body 里 human: true`,
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
const plan = splitRequirement({
|
|
596
|
+
title: body?.title,
|
|
597
|
+
description: body?.description ?? '',
|
|
598
|
+
criteria: body?.criteria ?? [],
|
|
599
|
+
parallel: body?.parallel !== false,
|
|
600
|
+
workspaceId: body?.workspaceId ?? null,
|
|
601
|
+
agentPreset: body?.agentPreset ?? null,
|
|
602
|
+
permission: body?.permission ?? current.permission ?? 'workspace-write',
|
|
603
|
+
// 交付门槛开着才生成验收卡与证据要求;关掉就是省一张卡(它的会话费在团队模式下本来也不额外计)。
|
|
604
|
+
requireEvidence: current.gates.requireEvidence === true,
|
|
605
|
+
// 智能体团队由开关决定;父卡带 teamRun,子卡不带(子卡是 Lead 派出去的 teammate)。
|
|
606
|
+
teamRun: current.execution?.teamRun === true,
|
|
607
|
+
intros: sanitizeIntros(body?.intros),
|
|
608
|
+
})
|
|
609
|
+
// 拆分本身不花钱(只算文本),所以闸门卡的是**运行信封**:闸门不允许时
|
|
610
|
+
// `run` 直接为 null,页面拿不到可执行的指令,就不会误开一轮。
|
|
611
|
+
// 计划闸的「批准」在这一层只能来自人点「写入并运行」这一下——插件无法证明人
|
|
612
|
+
// 真的读过拆分,这点在文档里写明了,不假装能证明。
|
|
613
|
+
const gate = evaluateStart({
|
|
614
|
+
config: current,
|
|
615
|
+
state: {},
|
|
616
|
+
request: { planApproved: body?.planApproved === true },
|
|
617
|
+
})
|
|
618
|
+
return { status: 200, body: { ok: true, ...plan, run: gate.allow ? plan.run : null, gate } }
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return {
|
|
621
|
+
status: 400,
|
|
622
|
+
body: { ok: false, error: { code: 'E_SCHEMA_INVALID', message: error?.message ?? String(error) } },
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
/** 运行期配置的唯一读取口:后续闸门/派发都从这里拿,避免两处配置漂移。 */
|
|
629
|
+
getConfig: () => current,
|
|
630
|
+
async handle(request) {
|
|
631
|
+
const method = String(request.method ?? 'GET').toUpperCase()
|
|
632
|
+
const pathname = String(request.pathname ?? '/')
|
|
633
|
+
|
|
634
|
+
if (method === 'GET' && (pathname === '/' || pathname === '')) {
|
|
635
|
+
try {
|
|
636
|
+
const { html, version } = readConsole()
|
|
637
|
+
return { status: 200, body: html, content: 'text/html; charset=utf-8', version }
|
|
638
|
+
} catch (error) {
|
|
639
|
+
return {
|
|
640
|
+
status: 500,
|
|
641
|
+
body: { ok: false, error: { code: 'E_CONSOLE_MISSING', message: error?.message ?? '页面读取失败' } },
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (method === 'GET' && pathname === '/view') {
|
|
647
|
+
const { version } = readConsole()
|
|
648
|
+
return { status: 200, body: snapshot({ version, workspaces: safeWorkspaces() }) }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (method === 'POST' && pathname === '/split') return splitRouteHandler(request)
|
|
652
|
+
if (method === 'POST' && pathname === '/rework') return reworkRoute(request)
|
|
653
|
+
if (method === 'POST' && pathname === '/import') return importRoute(request)
|
|
654
|
+
if (method === 'GET' && pathname === '/models') return modelsRoute(request)
|
|
655
|
+
if (method === 'POST' && pathname === '/criteria') return criteriaRoute(request)
|
|
656
|
+
if (method === 'POST' && pathname === '/parse') return parseRoute(request)
|
|
657
|
+
if (method === 'POST' && pathname === '/plan') return planRoute(request)
|
|
658
|
+
if (method === 'GET' && pathname === '/status') return statusRoute()
|
|
659
|
+
if (method === 'GET' && pathname === '/config') {
|
|
660
|
+
return { status: 200, body: { ok: true, configFile: store?.file ?? null, config: describeConfig(current) } }
|
|
661
|
+
}
|
|
662
|
+
if (method === 'POST' && pathname === '/config') return configRoute(request)
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
status: 404,
|
|
666
|
+
body: {
|
|
667
|
+
ok: false,
|
|
668
|
+
error: {
|
|
669
|
+
code: 'E_NOT_FOUND',
|
|
670
|
+
message: `没有这个路由:${method} ${pathname}(本插件只有 / 、/view、/split、/status、/config、/rework、/import、/criteria、/models、/parse、/plan)`,
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function createHttpHandler(channel, options = {}) {
|
|
679
|
+
const send = (res, status, body, contentType = 'application/json; charset=utf-8') => {
|
|
680
|
+
res.statusCode = status
|
|
681
|
+
res.setHeader('content-type', contentType)
|
|
682
|
+
res.setHeader('cache-control', 'no-store')
|
|
683
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body ?? null))
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return async function handleHttp(req, res) {
|
|
687
|
+
const remote = req.socket?.remoteAddress ?? ''
|
|
688
|
+
if (!isLoopbackAddress(remote)) {
|
|
689
|
+
send(res, 403, { ok: false, error: { code: 'E_NOT_LOOPBACK', message: '控制台只接受本机回环请求' } })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
const rejection = options.connection?.requestRejection?.(req)
|
|
693
|
+
if (rejection !== undefined) {
|
|
694
|
+
send(res, rejection, { ok: false, error: { code: 'E_UNTRUSTED_CONNECTION', message: `宿主连接栅栏拒绝:${rejection}` } })
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const url = new URL(String(req.url ?? '/'), 'http://localhost')
|
|
699
|
+
const pathname = url.pathname.startsWith(HUMAN_ROUTE_PREFIX)
|
|
700
|
+
? url.pathname.slice(HUMAN_ROUTE_PREFIX.length) || '/'
|
|
701
|
+
: url.pathname
|
|
702
|
+
|
|
703
|
+
let body
|
|
704
|
+
if (String(req.method).toUpperCase() === 'POST') {
|
|
705
|
+
try {
|
|
706
|
+
const text = await readBoundedBody(req)
|
|
707
|
+
body = text.trim() === '' ? {} : JSON.parse(text)
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const status = error?.code === 'E_BODY_TOO_LARGE' ? 413 : 400
|
|
710
|
+
send(res, status, { ok: false, error: { code: error?.code ?? 'E_BAD_JSON', message: error?.message ?? '请求体不可解析' } })
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
const result = await channel.handle({
|
|
717
|
+
method: req.method,
|
|
718
|
+
pathname,
|
|
719
|
+
searchParams: url.searchParams,
|
|
720
|
+
headers: req.headers ?? {},
|
|
721
|
+
body,
|
|
722
|
+
remoteAddress: remote,
|
|
723
|
+
})
|
|
724
|
+
send(res, result.status, result.body, result.content)
|
|
725
|
+
} catch (error) {
|
|
726
|
+
send(res, 500, { ok: false, error: { code: 'E_INTERNAL', message: error?.message ?? String(error) } })
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 挂到宿主 webServer 上。服务可能晚于本插件出现,所以走 `ctx.inject(['webServer'])` 懒挂。
|
|
733
|
+
*
|
|
734
|
+
* @returns {() => void} 释放函数
|
|
735
|
+
*/
|
|
736
|
+
/**
|
|
737
|
+
* 组 channel 的依赖:除了 workspaces 要包一层惰性,其余原样透传。
|
|
738
|
+
*
|
|
739
|
+
* 单独抽出来是因为这里少写一个键**不会报错**——只会让某个功能悄悄失效
|
|
740
|
+
* (`models` 就被丢过一次:路由在,但永远回「宿主没有提供模型清单」)。
|
|
741
|
+
* 所以这条路径单独有单测盯着。
|
|
742
|
+
*
|
|
743
|
+
* @param {object} deps 宿主给的依赖(config / store / facts / models …)
|
|
744
|
+
* @param {() => Array} resolveWorkspaces 惰性的工作区读取
|
|
745
|
+
* @returns {object}
|
|
746
|
+
*/
|
|
747
|
+
export function channelDeps(deps = {}, resolveWorkspaces = () => []) {
|
|
748
|
+
return { ...deps, workspaces: resolveWorkspaces }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function registerHumanChannel(ctx, deps = {}) {
|
|
752
|
+
/** 宿主的 workspaceRegistry 只在 mount 时拿得到,所以用一个可变引用喂给 channel。 */
|
|
753
|
+
let workspaces = () => []
|
|
754
|
+
const channel = createHumanChannel(channelDeps(deps, () => workspaces()))
|
|
755
|
+
const disposers = []
|
|
756
|
+
|
|
757
|
+
const mount = (hostCtx) => {
|
|
758
|
+
const webServer = hostCtx.webServer ?? hostCtx.get?.('webServer')
|
|
759
|
+
if (!webServer || typeof webServer.register !== 'function') return
|
|
760
|
+
workspaces = () => {
|
|
761
|
+
const registry = hostCtx.get?.('workspaceRegistry') ?? hostCtx.workspaceRegistry
|
|
762
|
+
const items = registry?.list?.() ?? []
|
|
763
|
+
return items.map((item) => ({
|
|
764
|
+
id: String(item?.id ?? ''),
|
|
765
|
+
title: String(item?.title ?? item?.name ?? ''),
|
|
766
|
+
path: String(item?.path ?? item?.rootPath ?? ''),
|
|
767
|
+
})).filter((item) => item.id !== '')
|
|
768
|
+
}
|
|
769
|
+
const connection = hostCtx.get?.('connection')
|
|
770
|
+
const handler = createHttpHandler(channel, { connection })
|
|
771
|
+
const effect = typeof hostCtx.effect === 'function' ? hostCtx.effect.bind(hostCtx) : (fn) => fn()
|
|
772
|
+
const dispose = effect(
|
|
773
|
+
() => webServer.register({ kind: 'prefix', path: HUMAN_ROUTE_PREFIX, handler }),
|
|
774
|
+
`card-dispatch: console ${HUMAN_ROUTE_PREFIX}`,
|
|
775
|
+
)
|
|
776
|
+
disposers.push(typeof dispose === 'function' ? dispose : () => {})
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (typeof ctx.inject === 'function') {
|
|
780
|
+
ctx.inject(['webServer'], (hostCtx) => mount(hostCtx))
|
|
781
|
+
} else {
|
|
782
|
+
mount(ctx)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return () => {
|
|
786
|
+
for (const dispose of disposers.splice(0)) {
|
|
787
|
+
try {
|
|
788
|
+
dispose()
|
|
789
|
+
} catch {
|
|
790
|
+
// 释放失败不影响卸载:宿主会随 fiber 一起回收
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|