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
package/src/import.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* import.js:从需求文档里抽出「标题 / 描述 / 判据」。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么放在服务端
|
|
5
|
+
*
|
|
6
|
+
* 抽取规则如果写在页面里,就没法单测,也没法解释「为什么这条判据没被认出来」。
|
|
7
|
+
* 这里做成纯函数:输入文件名 + 文本,输出三个字段 + 一串警告,页面只负责填。
|
|
8
|
+
*
|
|
9
|
+
* ## 规则(刻意保守,认不出来就说,不猜)
|
|
10
|
+
*
|
|
11
|
+
* 1. **标题**:第一个一级标题;没有就用文件名(去掉扩展名);再没有就用第一行短文本。
|
|
12
|
+
* 2. **判据**:先找「验收判据 / 验收标准 / 判据 / acceptance criteria」这类标题,
|
|
13
|
+
* 把它下面到下一个同级标题之间的列表项收起来;找不到标题就退一步,
|
|
14
|
+
* 全篇找 `- [ ]` 这种勾选框。
|
|
15
|
+
* 3. **描述**:全文去掉标题行和判据那一段,超过 6000 字截断并警告——
|
|
16
|
+
* 描述会原样进 prompt,几万字的文档会把它撑爆。
|
|
17
|
+
*
|
|
18
|
+
* 明确不支持:.docx / .pdf(浏览器解不了)。与其假装能读,不如让人先另存为文本。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 描述进 prompt 的上限:超过就截断(一条警告,不静默)。 */
|
|
22
|
+
export const MAX_DESCRIPTION = 6000
|
|
23
|
+
|
|
24
|
+
/** 判据标题的识别词:中文优先,带编号前缀也认(「## 4. 验收判据」)。 */
|
|
25
|
+
const CRITERIA_HEADING = /(验收判据|验收标准|验收条件|判据|acceptance\s+criteria|^ac$)/i
|
|
26
|
+
|
|
27
|
+
/** 列表项:`- x`、`* x`、`1. x`、`1) x`,允许带 `[ ]` / `[x]` 勾选框。 */
|
|
28
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?(.+?)\s*$/
|
|
29
|
+
|
|
30
|
+
/** 勾选框条目(判据的强信号,用于没有标题时的兜底)。 */
|
|
31
|
+
const CHECKBOX_ITEM = /^\s*[-*+]\s+\[[ xX]\]\s+(.+?)\s*$/
|
|
32
|
+
|
|
33
|
+
const SUPPORTED = /\.(md|markdown|txt|text|json)$/i
|
|
34
|
+
|
|
35
|
+
function headingOf(line) {
|
|
36
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line)
|
|
37
|
+
return match === null ? null : { level: match[1].length, text: match[2].trim() }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 去掉列表符号 / 勾选框 / 行内加粗,留下判据正文。 */
|
|
41
|
+
function cleanItem(text) {
|
|
42
|
+
return String(text ?? '')
|
|
43
|
+
.replace(/^\s*[-*+]\s+/, '')
|
|
44
|
+
.replace(/^\s*\[[ xX]\]\s*/, '')
|
|
45
|
+
.replace(/\*\*/g, '')
|
|
46
|
+
.replace(/`/g, '')
|
|
47
|
+
.trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function baseName(filename) {
|
|
51
|
+
const name = String(filename ?? '').split(/[\\/]/).pop() ?? ''
|
|
52
|
+
return name.replace(/\.[^.]+$/, '').trim()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeText(text) {
|
|
56
|
+
return String(text ?? '')
|
|
57
|
+
.replace(/^\uFEFF/, '')
|
|
58
|
+
.replace(/\r\n?/g, '\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 解析需求文档。
|
|
63
|
+
*
|
|
64
|
+
* @param {object} input
|
|
65
|
+
* @param {string} [input.filename] 文件名(用来判断格式、兜底标题)
|
|
66
|
+
* @param {string} [input.text] 文档正文
|
|
67
|
+
* @returns {{ok:boolean, format:string, title:string, description:string, criteria:Array<string>, warnings:Array<string>}}
|
|
68
|
+
*/
|
|
69
|
+
export function parseRequirementDoc({ filename = '', text = '' } = {}) {
|
|
70
|
+
const warnings = []
|
|
71
|
+
const name = String(filename ?? '')
|
|
72
|
+
|
|
73
|
+
if (name !== '' && !SUPPORTED.test(name)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
format: 'unsupported',
|
|
77
|
+
title: '',
|
|
78
|
+
description: '',
|
|
79
|
+
criteria: [],
|
|
80
|
+
warnings: [
|
|
81
|
+
`不支持 ${name.split('.').pop() || '这种'} 格式:浏览器里读不出 docx / pdf 的正文。` +
|
|
82
|
+
'先在文档里「另存为」.md 或 .txt,再导入。',
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const raw = normalizeText(text)
|
|
88
|
+
if (raw.trim() === '') {
|
|
89
|
+
return { ok: false, format: 'empty', title: '', description: '', criteria: [], warnings: ['文件是空的'] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// JSON:允许直接给结构化需求,省得再猜
|
|
93
|
+
if (/\.json$/i.test(name)) {
|
|
94
|
+
try {
|
|
95
|
+
const data = JSON.parse(raw)
|
|
96
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
97
|
+
const criteria = Array.isArray(data.criteria)
|
|
98
|
+
? data.criteria.map((item) => cleanItem(typeof item === 'string' ? item : item?.text ?? '')).filter((item) => item !== '')
|
|
99
|
+
: []
|
|
100
|
+
const title = String(data.title ?? '').trim() || baseName(name)
|
|
101
|
+
if (criteria.length === 0) warnings.push('这个 JSON 里没有 criteria 数组,判据需要手填')
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
format: 'json',
|
|
105
|
+
title,
|
|
106
|
+
description: String(data.description ?? '').slice(0, MAX_DESCRIPTION),
|
|
107
|
+
criteria,
|
|
108
|
+
warnings,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
warnings.push(`JSON 解析失败(${error.message}),按纯文本处理`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lines = raw.split('\n')
|
|
117
|
+
|
|
118
|
+
// 1) 标题
|
|
119
|
+
let title = ''
|
|
120
|
+
let titleIndex = -1
|
|
121
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
122
|
+
const heading = headingOf(lines[index])
|
|
123
|
+
if (heading !== null && heading.level === 1) {
|
|
124
|
+
title = heading.text
|
|
125
|
+
titleIndex = index
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (title === '') {
|
|
130
|
+
const first = lines.find((line) => line.trim() !== '')
|
|
131
|
+
if (first !== undefined && !LIST_ITEM.test(first) && first.trim().length <= 80) {
|
|
132
|
+
title = first.trim()
|
|
133
|
+
titleIndex = lines.indexOf(first)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (title === '') {
|
|
137
|
+
title = baseName(name)
|
|
138
|
+
if (title !== '') warnings.push(`没找到标题,用文件名「${title}」顶上`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2) 判据:先找标题段,再退到勾选框
|
|
142
|
+
let criteria = []
|
|
143
|
+
let criteriaStart = -1
|
|
144
|
+
let criteriaEnd = -1
|
|
145
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
146
|
+
const heading = headingOf(lines[index])
|
|
147
|
+
if (heading === null || !CRITERIA_HEADING.test(heading.text)) continue
|
|
148
|
+
criteriaStart = index
|
|
149
|
+
let end = lines.length
|
|
150
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
151
|
+
const next = headingOf(lines[cursor])
|
|
152
|
+
if (next !== null && next.level <= heading.level) {
|
|
153
|
+
end = cursor
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
criteriaEnd = end
|
|
158
|
+
criteria = lines
|
|
159
|
+
.slice(index + 1, end)
|
|
160
|
+
.map((line) => {
|
|
161
|
+
const match = LIST_ITEM.exec(line)
|
|
162
|
+
return match === null ? '' : cleanItem(match[1])
|
|
163
|
+
})
|
|
164
|
+
.filter((item) => item !== '')
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (criteria.length === 0) {
|
|
169
|
+
criteria = lines
|
|
170
|
+
.map((line) => CHECKBOX_ITEM.exec(line))
|
|
171
|
+
.filter((match) => match !== null)
|
|
172
|
+
.map((match) => cleanItem(match[1]))
|
|
173
|
+
if (criteria.length > 0) warnings.push('没找到「验收判据」标题,按文档里的勾选框条目取判据')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (criteria.length === 0) {
|
|
177
|
+
warnings.push('没认出验收判据:请在文档里加一段「## 验收判据」并用 `- ` 列表写,或导入后手填')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3) 描述:去掉标题行与判据段
|
|
181
|
+
const kept = lines.filter((line, index) => {
|
|
182
|
+
if (index === titleIndex) return false
|
|
183
|
+
if (criteriaStart >= 0 && index >= criteriaStart && index < criteriaEnd) return false
|
|
184
|
+
return true
|
|
185
|
+
})
|
|
186
|
+
let description = kept.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
187
|
+
if (description.length > MAX_DESCRIPTION) {
|
|
188
|
+
description = description.slice(0, MAX_DESCRIPTION)
|
|
189
|
+
warnings.push(`描述超过 ${MAX_DESCRIPTION} 字,已截断(它会原样进 prompt,太长会挤掉判据)`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { ok: true, format: 'text', title, description, criteria, warnings }
|
|
193
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-call.js:问宿主模型要一段 JSON 回复的公共通道。
|
|
3
|
+
*
|
|
4
|
+
* 判据草稿和整段需求解析走的是同一条路(`prepareCall` 有就用,没有就退回
|
|
5
|
+
* `stream`,只取 `text-delta`),逻辑一样,所以抽出来——两边各写一遍迟早分叉。
|
|
6
|
+
*
|
|
7
|
+
* 不自己拼 HTTP、不碰密钥:模型与凭据都归宿主管。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 一次调用最多等多久(超时即失败,让调用方退回规则提炼)。 */
|
|
11
|
+
export const MODEL_TIMEOUT_MS = 45_000
|
|
12
|
+
|
|
13
|
+
/** 旧版 llm 服务没有 createUserMessage 时用的兜底形状。 */
|
|
14
|
+
function fallbackMessage(text) {
|
|
15
|
+
return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function buildMessage(text) {
|
|
19
|
+
try {
|
|
20
|
+
const module = await import('@deepseek-ai/dsh-llm')
|
|
21
|
+
if (typeof module.createUserMessage === 'function') {
|
|
22
|
+
return module.createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/* 宿主没把这个包暴露给我们:用兜底形状,失败就退回规则 */
|
|
26
|
+
}
|
|
27
|
+
return fallbackMessage(text)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 让模型回一段文本。
|
|
32
|
+
*
|
|
33
|
+
* @param {object} input
|
|
34
|
+
* @param {any} input.llm 宿主的 llm 服务
|
|
35
|
+
* @param {{provider:string, model:string}} input.route
|
|
36
|
+
* @param {string} input.system 系统提示
|
|
37
|
+
* @param {string} input.text 用户内容
|
|
38
|
+
* @param {number} [input.timeoutMs]
|
|
39
|
+
* @returns {Promise<{ok:boolean, reply:string, error:string|null}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function callModel({ llm, route, system, text, timeoutMs = MODEL_TIMEOUT_MS } = {}) {
|
|
42
|
+
if (llm === null || llm === undefined) return { ok: false, reply: '', error: '宿主没有提供 llm 服务' }
|
|
43
|
+
if (route === null || route === undefined) return { ok: false, reply: '', error: '没有可用的模型路由' }
|
|
44
|
+
if (typeof llm.stream !== 'function') return { ok: false, reply: '', error: 'llm 服务没有 stream 方法' }
|
|
45
|
+
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
48
|
+
try {
|
|
49
|
+
const call = { provider: route.provider, model: route.model }
|
|
50
|
+
const request = { system, messages: [await buildMessage(String(text ?? ''))], signal: controller.signal }
|
|
51
|
+
|
|
52
|
+
let stream
|
|
53
|
+
if (typeof llm.prepareCall === 'function') {
|
|
54
|
+
const prepared = await llm.prepareCall(call, controller.signal)
|
|
55
|
+
stream = prepared.stream({ ...prepared.config, ...request })
|
|
56
|
+
} else {
|
|
57
|
+
stream = await llm.stream({ ...call, ...request })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let reply = ''
|
|
61
|
+
for await (const chunk of stream) {
|
|
62
|
+
if (chunk !== null && chunk !== undefined && chunk.type === 'text-delta') reply += chunk.text
|
|
63
|
+
}
|
|
64
|
+
if (reply.trim() === '') return { ok: false, reply: '', error: '模型没有回内容' }
|
|
65
|
+
return { ok: true, reply, error: null }
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const aborted = controller.signal.aborted
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reply: '',
|
|
71
|
+
error: aborted ? `模型 ${timeoutMs / 1000}s 内没答上来` : String(error?.message ?? error),
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 从回复里抠出第一个 JSON 对象(容忍代码围栏与前后废话)。 */
|
|
79
|
+
export function extractJsonObject(reply) {
|
|
80
|
+
const text = String(reply ?? '').replace(/```[a-zA-Z]*\s*/g, '')
|
|
81
|
+
const start = text.indexOf('{')
|
|
82
|
+
const end = text.lastIndexOf('}')
|
|
83
|
+
if (start === -1 || end <= start) return undefined
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(text.slice(start, end + 1))
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* models.js:读出宿主「已经添加的模型」。
|
|
3
|
+
*
|
|
4
|
+
* 清单的唯一真相是 profile 的 `cordis.patch.yml`:`@deepseek-ai/dsh-llm-pi-ai`
|
|
5
|
+
* 这类 provider 插件把自己的 `providers.*.models` 写在那里,`agent-default-model`
|
|
6
|
+
* 写默认路由。插件只读它,不改它——加模型仍然是去设置里加。
|
|
7
|
+
*
|
|
8
|
+
* 解析交给调用方传进来的 YAML 解析器(`yaml` 包),这样本文件保持纯函数、能单测。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 把 `provider/model` 拼成一条路由。 */
|
|
12
|
+
export function routeOf(provider, model) {
|
|
13
|
+
return `${provider}/${model}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 拆 `provider/model`;不合法返回 null。 */
|
|
17
|
+
export function splitRoute(qualified) {
|
|
18
|
+
const raw = String(qualified ?? '').trim()
|
|
19
|
+
if (raw === '') return null
|
|
20
|
+
const slash = raw.indexOf('/')
|
|
21
|
+
if (slash <= 0 || slash === raw.length - 1) return null
|
|
22
|
+
const provider = raw.slice(0, slash).trim()
|
|
23
|
+
const model = raw.slice(slash + 1).trim()
|
|
24
|
+
return provider === '' || model === '' ? null : { provider, model }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 从解析后的 cordis 配置里建目录。
|
|
29
|
+
*
|
|
30
|
+
* 容忍一切畸形输入:读不出来就是空目录,页面据此提示「去设置里加模型」,
|
|
31
|
+
* 而不是抛异常把整个面板弄挂。
|
|
32
|
+
*
|
|
33
|
+
* @param {unknown} doc 解析后的 cordis.patch.yml(插件条目数组)
|
|
34
|
+
* @returns {{default:string|null, groups:Array<{provider:string, displayName:string, models:Array<{id:string,name:string}>}>}}
|
|
35
|
+
*/
|
|
36
|
+
export function buildCatalog(doc) {
|
|
37
|
+
const groups = []
|
|
38
|
+
let fallback = null
|
|
39
|
+
const entries = Array.isArray(doc) ? doc : []
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
42
|
+
const config = entry.config
|
|
43
|
+
if (config === null || typeof config !== 'object') continue
|
|
44
|
+
|
|
45
|
+
if (entry.id === 'agent-default-model' && typeof config.provider === 'string' && typeof config.model === 'string') {
|
|
46
|
+
fallback = routeOf(config.provider, config.model)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const providers = config.providers
|
|
50
|
+
if (providers === null || typeof providers !== 'object' || Array.isArray(providers)) continue
|
|
51
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
52
|
+
if (provider === null || typeof provider !== 'object') continue
|
|
53
|
+
const models = Array.isArray(provider.models)
|
|
54
|
+
? provider.models
|
|
55
|
+
.filter((model) => model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== '')
|
|
56
|
+
.map((model) => ({ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id }))
|
|
57
|
+
: []
|
|
58
|
+
if (models.length > 0) {
|
|
59
|
+
groups.push({
|
|
60
|
+
provider: providerId,
|
|
61
|
+
displayName: typeof provider.displayName === 'string' && provider.displayName !== '' ? provider.displayName : providerId,
|
|
62
|
+
models,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { default: fallback, groups }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 把目录摊平成下拉选项。
|
|
72
|
+
*
|
|
73
|
+
* @param {{default:string|null, groups:Array}} catalog
|
|
74
|
+
* @returns {Array<{route:string, label:string, provider:string, model:string, isDefault:boolean}>}
|
|
75
|
+
*/
|
|
76
|
+
export function listRoutes(catalog) {
|
|
77
|
+
const options = []
|
|
78
|
+
for (const group of catalog?.groups ?? []) {
|
|
79
|
+
for (const model of group.models) {
|
|
80
|
+
const route = routeOf(group.provider, model.id)
|
|
81
|
+
options.push({
|
|
82
|
+
route,
|
|
83
|
+
label: `${group.displayName} · ${model.name}`,
|
|
84
|
+
provider: group.provider,
|
|
85
|
+
model: model.id,
|
|
86
|
+
isDefault: route === catalog.default,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return options
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 解析默认路由:配置里指定的优先,其次宿主默认,最后取第一个可用。
|
|
95
|
+
*
|
|
96
|
+
* @param {string} configured 配置里的 `provider/model`(可空)
|
|
97
|
+
* @param {{default:string|null, groups:Array}} catalog
|
|
98
|
+
* @returns {string|null}
|
|
99
|
+
*/
|
|
100
|
+
export function pickRoute(configured, catalog) {
|
|
101
|
+
if (splitRoute(configured) !== null) return configured.trim()
|
|
102
|
+
if (catalog?.default) return catalog.default
|
|
103
|
+
const first = listRoutes(catalog)[0]
|
|
104
|
+
return first === undefined ? null : first.route
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ── 不依赖 yaml 包的兜底扫描 ─────────────────────────────────────────────
|
|
108
|
+
`yaml` 从插件真实路径不一定解析得到(插件是 junction 进去的),所以留一条
|
|
109
|
+
纯文本扫描:只认 `providers:` 这一块的结构,认不出就返回空目录,页面提示
|
|
110
|
+
「去设置里加模型」——不猜、不抛。 */
|
|
111
|
+
|
|
112
|
+
function unquote(value) {
|
|
113
|
+
const text = String(value ?? '').trim()
|
|
114
|
+
if (text.length >= 2 && (text[0] === '"' || text[0] === "'") && text[text.length - 1] === text[0]) {
|
|
115
|
+
return text.slice(1, -1)
|
|
116
|
+
}
|
|
117
|
+
return text
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function indentOf(line) {
|
|
121
|
+
return line.length - line.trimStart().length
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 扫描 `agent-default-model` 条目的 provider / model。
|
|
126
|
+
*
|
|
127
|
+
* @param {Array<string>} lines
|
|
128
|
+
* @returns {string|null}
|
|
129
|
+
*/
|
|
130
|
+
function scanDefault(lines) {
|
|
131
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
132
|
+
if (!/^-\s+id:\s*agent-default-model\s*$/.test(lines[index].trim())) continue
|
|
133
|
+
let provider = null
|
|
134
|
+
let model = null
|
|
135
|
+
for (let cursor = index + 1; cursor < Math.min(index + 12, lines.length); cursor += 1) {
|
|
136
|
+
const line = lines[cursor].trim()
|
|
137
|
+
if (/^-\s+id:/.test(line)) break
|
|
138
|
+
const p = /^provider:\s*(.+)$/.exec(line)
|
|
139
|
+
const m = /^model:\s*(.+)$/.exec(line)
|
|
140
|
+
if (p !== null) provider = unquote(p[1])
|
|
141
|
+
if (m !== null) model = unquote(m[1])
|
|
142
|
+
}
|
|
143
|
+
if (provider !== null && model !== null) return routeOf(provider, model)
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 从 YAML 原文里扫出 provider → models(只认 `providers:` 块)。
|
|
150
|
+
*
|
|
151
|
+
* @param {string} text
|
|
152
|
+
* @returns {{default:string|null, groups:Array<{provider:string, displayName:string, models:Array<{id:string,name:string}>}>}}
|
|
153
|
+
*/
|
|
154
|
+
export function scanProviders(text) {
|
|
155
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
|
|
156
|
+
const groups = []
|
|
157
|
+
let providersIndent = -1
|
|
158
|
+
let providerIndent = -1
|
|
159
|
+
let current = null
|
|
160
|
+
let inModels = false
|
|
161
|
+
let modelIndent = -1
|
|
162
|
+
let lastModel = null
|
|
163
|
+
|
|
164
|
+
const close = () => {
|
|
165
|
+
if (current !== null && current.models.length > 0) groups.push(current)
|
|
166
|
+
current = null
|
|
167
|
+
inModels = false
|
|
168
|
+
modelIndent = -1
|
|
169
|
+
lastModel = null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const raw of lines) {
|
|
173
|
+
const line = raw.trim()
|
|
174
|
+
if (line === '' || line.startsWith('#')) continue
|
|
175
|
+
const indent = indentOf(raw)
|
|
176
|
+
|
|
177
|
+
if (providersIndent === -1) {
|
|
178
|
+
if (/^providers:\s*$/.test(line)) providersIndent = indent
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (indent <= providersIndent) {
|
|
182
|
+
close()
|
|
183
|
+
if (/^providers:\s*$/.test(line)) {
|
|
184
|
+
providersIndent = indent
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
providersIndent = -1
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
if (providerIndent === -1) providerIndent = indent
|
|
191
|
+
if (indent < providerIndent) {
|
|
192
|
+
close()
|
|
193
|
+
providerIndent = indent
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (indent === providerIndent) {
|
|
197
|
+
const key = /^([\w.-]+):\s*$/.exec(line)
|
|
198
|
+
if (key !== null) {
|
|
199
|
+
close()
|
|
200
|
+
current = { provider: key[1], displayName: key[1], models: [] }
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (current === null) continue
|
|
205
|
+
|
|
206
|
+
const display = /^displayName:\s*(.+)$/.exec(line)
|
|
207
|
+
if (display !== null) {
|
|
208
|
+
current.displayName = unquote(display[1])
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (/^models:\s*$/.test(line)) {
|
|
212
|
+
inModels = true
|
|
213
|
+
modelIndent = -1
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
if (!inModels) continue
|
|
217
|
+
|
|
218
|
+
/* 只认 models 列表**同一层**的 `- ` 项。模型自己的 `input:` 下面也有 `- text`,
|
|
219
|
+
层级更深——不按缩进卡住的话,它们会被当成模型。 */
|
|
220
|
+
if (modelIndent === -1 && /^-\s+/.test(line)) modelIndent = indent
|
|
221
|
+
|
|
222
|
+
const byId = /^-\s+id:\s*(.+)$/.exec(line)
|
|
223
|
+
if (byId !== null && indent === modelIndent) {
|
|
224
|
+
lastModel = { id: unquote(byId[1]), name: unquote(byId[1]) }
|
|
225
|
+
current.models.push(lastModel)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const bare = /^-\s+([\w.-]+)\s*$/.exec(line)
|
|
229
|
+
if (bare !== null && indent === modelIndent) {
|
|
230
|
+
lastModel = { id: bare[1], name: bare[1] }
|
|
231
|
+
current.models.push(lastModel)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
const name = /^name:\s*(.+)$/.exec(line)
|
|
235
|
+
if (name !== null && lastModel !== null && indent > modelIndent) {
|
|
236
|
+
lastModel.name = unquote(name[1])
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
// 回到 provider 层的键(displayName / 下一个 provider)就结束 models 块
|
|
240
|
+
if (!/^-\s+/.test(line) && modelIndent !== -1 && indent <= modelIndent) {
|
|
241
|
+
inModels = false
|
|
242
|
+
modelIndent = -1
|
|
243
|
+
lastModel = null
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
close()
|
|
247
|
+
|
|
248
|
+
return { default: scanDefault(lines), groups }
|
|
249
|
+
}
|
package/src/parse-llm.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse-llm.js:把粘贴进来的一段需求原文,交给宿主模型整理成
|
|
3
|
+
* 「标题 + 描述 + 验收判据」三个字段。
|
|
4
|
+
*
|
|
5
|
+
* 和 criteria-llm 的差别只在提示词与解析:这里要一次要三个字段,
|
|
6
|
+
* 而且**不许补原文没有的东西**——编出来的功能会变成一张跑歪的卡。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { callModel, extractJsonObject, MODEL_TIMEOUT_MS } from './model-call.js'
|
|
10
|
+
|
|
11
|
+
export { MODEL_TIMEOUT_MS as PARSE_TIMEOUT_MS }
|
|
12
|
+
|
|
13
|
+
/** 判据条数与长度上限(和规则提炼保持一致)。 */
|
|
14
|
+
const MAX_ITEMS = 8
|
|
15
|
+
const ITEM_LIMIT = 120
|
|
16
|
+
/** 标题上限:看板卡片上放得下。 */
|
|
17
|
+
const TITLE_LIMIT = 30
|
|
18
|
+
/** 描述上限:和导入功能一致,太长的正文不进 prompt。 */
|
|
19
|
+
const DESCRIPTION_LIMIT = 6000
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 系统提示词。单独导出,测试与 README 都引用这同一份,避免文档和代码说两套。
|
|
23
|
+
*/
|
|
24
|
+
export const PARSE_SYSTEM = [
|
|
25
|
+
'你把用户粘贴的一段需求原文,整理成任务看板上的一张卡片要的三个字段。',
|
|
26
|
+
'只输出一个 JSON 对象,不要任何解释:',
|
|
27
|
+
'{"title":"...","description":"...","criteria":["...","..."]}',
|
|
28
|
+
'',
|
|
29
|
+
'规则:',
|
|
30
|
+
'- title:一句话说清要做什么,不超过 24 个字;不要以「需求」「文档」「说明」这类空词开头。',
|
|
31
|
+
'- description:整理成「给谁用、要什么、明确不要什么」;保留原文里的具体数字、型号、接口名;原文没有的信息一个字都不要补。',
|
|
32
|
+
'- criteria:3 到 8 条,每条一句话,必须是能判定真假的验收条件(看到什么现象算通过),不要写任务步骤,不要写「代码质量好」这种没法核对的话。',
|
|
33
|
+
'- 用原文本来使用的语言写。',
|
|
34
|
+
].join('\n')
|
|
35
|
+
|
|
36
|
+
function cleanLine(value) {
|
|
37
|
+
return String(value ?? '')
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.replace(/^[-*+]\s+/, '')
|
|
40
|
+
.trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 解析模型回复。
|
|
45
|
+
*
|
|
46
|
+
* @param {string} reply
|
|
47
|
+
* @returns {{ok:boolean, title:string, description:string, criteria:Array<string>, error:string|null}}
|
|
48
|
+
*/
|
|
49
|
+
export function parseRequirementReply(reply) {
|
|
50
|
+
const payload = extractJsonObject(reply)
|
|
51
|
+
if (payload === undefined || payload === null || typeof payload !== 'object') {
|
|
52
|
+
return { ok: false, title: '', description: '', criteria: [], error: '模型回复不是约定的 JSON' }
|
|
53
|
+
}
|
|
54
|
+
const title = cleanLine(payload.title).slice(0, TITLE_LIMIT)
|
|
55
|
+
const description = cleanLine(payload.description).slice(0, DESCRIPTION_LIMIT)
|
|
56
|
+
const raw = Array.isArray(payload.criteria) ? payload.criteria : []
|
|
57
|
+
const seen = new Set()
|
|
58
|
+
const criteria = []
|
|
59
|
+
for (const item of raw) {
|
|
60
|
+
const line = cleanLine(typeof item === 'string' ? item : item?.text).slice(0, ITEM_LIMIT)
|
|
61
|
+
if (line === '') continue
|
|
62
|
+
const key = line.replace(/[\s。;;,,::]/g, '')
|
|
63
|
+
if (seen.has(key)) continue
|
|
64
|
+
seen.add(key)
|
|
65
|
+
criteria.push(line)
|
|
66
|
+
if (criteria.length >= MAX_ITEMS) break
|
|
67
|
+
}
|
|
68
|
+
if (title === '' && criteria.length === 0) {
|
|
69
|
+
return { ok: false, title: '', description: '', criteria: [], error: '模型没给出标题,也没给出判据' }
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, title, description, criteria, error: null }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 让模型解析一段需求原文。
|
|
76
|
+
*
|
|
77
|
+
* @param {object} input
|
|
78
|
+
* @param {any} input.llm
|
|
79
|
+
* @param {{provider:string, model:string}} input.route
|
|
80
|
+
* @param {string} input.text 需求原文
|
|
81
|
+
* @param {number} [input.timeoutMs]
|
|
82
|
+
* @returns {Promise<{ok:boolean, title:string, description:string, criteria:Array<string>, error:string|null}>}
|
|
83
|
+
*/
|
|
84
|
+
export async function parseRequirementWithModel({ llm, route, text = '', timeoutMs = MODEL_TIMEOUT_MS } = {}) {
|
|
85
|
+
const source = String(text ?? '').trim()
|
|
86
|
+
if (source === '') return { ok: false, title: '', description: '', criteria: [], error: '没有可解析的原文' }
|
|
87
|
+
const reply = await callModel({
|
|
88
|
+
llm,
|
|
89
|
+
route,
|
|
90
|
+
system: PARSE_SYSTEM,
|
|
91
|
+
text: `需求原文:\n${source.slice(0, DESCRIPTION_LIMIT)}`,
|
|
92
|
+
timeoutMs,
|
|
93
|
+
})
|
|
94
|
+
if (!reply.ok) return { ok: false, title: '', description: '', criteria: [], error: reply.error }
|
|
95
|
+
return parseRequirementReply(reply.reply)
|
|
96
|
+
}
|