xiaoyuan-assistant 0.5.45

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.
@@ -0,0 +1,434 @@
1
+ function normalize(text = '') {
2
+ return String(text)
3
+ .toLowerCase()
4
+ .replace(/[,。!?、;:,.!?;:()()“”"'‘’]/g, ' ')
5
+ .replace(/\s+/g, ' ')
6
+ .trim()
7
+ }
8
+
9
+ /**
10
+ * 把一句自然语言拆成最小、可独立执行的指令片段。
11
+ * action 与 analysis 都会单独成为 step,顺序严格保持不变。
12
+ */
13
+ export function splitLocalCommands(message = '', manifest = {}) {
14
+ const raw = String(message || '').replace(/\s+/g, ' ').trim()
15
+ if (!raw) return []
16
+
17
+ // 1) 明确连接词/标点优先切分;后续每段仍会继续做“能力锚点”切分。
18
+ const explicit = raw
19
+ .split(/(?:\s*(?:然后|接着|随后|之后|再|并且|并|以及|同时|另外|其次|最后|再然后)\s*)|\s*[;;。!?]\s*|\s*\n+\s*/)
20
+ .map((item) => item.trim())
21
+ .filter(Boolean)
22
+
23
+ const candidates = mergeCapabilities(manifest.functions, manifest.domElements)
24
+
25
+ const splitByAnchors = (text) => {
26
+ const localText = String(text || '').trim()
27
+ if (!localText) return []
28
+
29
+ const anchors = []
30
+ const lowerRaw = localText.toLowerCase()
31
+
32
+ const addAnchor = (value, item) => {
33
+ const needle = String(value ?? '').trim()
34
+ if (!needle) return
35
+ const lowerNeedle = needle.toLowerCase()
36
+ let from = 0
37
+ while (from < localText.length) {
38
+ const idx = lowerRaw.indexOf(lowerNeedle, from)
39
+ if (idx < 0) break
40
+ anchors.push({
41
+ start: idx,
42
+ end: idx + needle.length,
43
+ value,
44
+ item
45
+ })
46
+ from = idx + Math.max(1, needle.length)
47
+ }
48
+ }
49
+
50
+ for (const item of candidates) {
51
+ for (const value of item.params || []) addAnchor(value, item)
52
+ }
53
+
54
+ // 年份是“动态参数”:当前页面可能尚未渲染出年份 DOM,也必须先完成拆分。
55
+ const yearMatches = [...localText.matchAll(/(?:19|20)\d{2}年?/g)]
56
+ for (const match of yearMatches) {
57
+ const value = match[0]
58
+ const preferred = candidates.find((item) => {
59
+ const corpus = normalize(`${item.name || ''} ${item.description || ''}`)
60
+ return /年份|年|时间/.test(corpus) || /changeyear/i.test(String(item.name || ''))
61
+ })
62
+ if (preferred) {
63
+ addAnchor(value, preferred)
64
+ }
65
+ }
66
+
67
+ if (!anchors.length) return [localText]
68
+
69
+ // 长匹配优先,避免“物联监测”同时命中“监测”造成错误切分。
70
+ anchors.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start))
71
+ const filtered = []
72
+ for (const anchor of anchors) {
73
+ const overlap = filtered.some((item) => anchor.start < item.end && anchor.end > item.start)
74
+ if (!overlap) filtered.push(anchor)
75
+ }
76
+ filtered.sort((a, b) => a.start - b.start)
77
+
78
+ if (filtered.length <= 1) return [localText]
79
+
80
+ const chunks = []
81
+
82
+ // 关键修复:每个“参数锚点”定义一个真实指令边界。
83
+ // 示例:
84
+ // “切换到物联监测切换到基地资源切换到集成监管时间切换到2024”
85
+ // =>
86
+ // “切换到物联监测”
87
+ // “切换到基地资源”
88
+ // “切换到集成监管”
89
+ // “时间切换到2024”
90
+ for (let i = 0; i < filtered.length; i += 1) {
91
+ const current = filtered[i]
92
+ const start = i === 0 ? 0 : filtered[i - 1].end
93
+ const end = current.end
94
+ let chunk = localText.slice(start, end).trim()
95
+
96
+ chunk = chunk
97
+ .replace(/^(?:然后|接着|随后|之后|再|并且|并|以及|同时|另外|其次|最后|再然后|和|且|,|,|、)+/i, '')
98
+ .trim()
99
+
100
+ if (chunk) chunks.push(chunk)
101
+ }
102
+
103
+ // 最后一个参数之后如果还有自然语言,必须保留为独立指令,尤其是:
104
+ // “切换到2024,查看一下土壤墒情数据”
105
+ const last = filtered[filtered.length - 1]
106
+ const tail = localText.slice(last.end).trim()
107
+ .replace(/^(?:然后|接着|随后|之后|再|并且|并|以及|同时|另外|其次|最后|再然后|和|且|,|,|、)+/i, '')
108
+ .trim()
109
+ if (tail) chunks.push(tail)
110
+
111
+ return chunks
112
+ }
113
+
114
+ // 即使存在连接词,也继续检查每个片段内部是否粘连了多个动作。
115
+ // 这样“切换到A,并切换到B切换到C时间切换到2024”也能拆开。
116
+ const source = explicit.length > 1 ? explicit : [raw]
117
+ const result = []
118
+ for (const item of source) {
119
+ result.push(...splitByAnchors(item))
120
+ }
121
+
122
+ return result.filter(Boolean)
123
+ }
124
+
125
+ function valueText(value) {
126
+ return normalize(typeof value === 'object' ? JSON.stringify(value) : value)
127
+ }
128
+
129
+ function valueInText(value, text) {
130
+ const v = valueText(value)
131
+ const t = normalize(text)
132
+ return Boolean(v && t && t.includes(v))
133
+ }
134
+
135
+ function buildCompactText(text = '') {
136
+ return normalize(text).replace(/\s/g, '')
137
+ }
138
+
139
+ function phraseScore(clause, phrase) {
140
+ const a = buildCompactText(clause)
141
+ const b = buildCompactText(phrase)
142
+ if (!a || !b) return 0
143
+ if (a.includes(b)) return Math.min(12, 4 + b.length / 2)
144
+ // 很轻量的中文关键词重叠,不做昂贵的全 DOM 模糊搜索。
145
+ let hit = 0
146
+ for (const ch of new Set(b)) if (a.includes(ch)) hit += 1
147
+ return hit / Math.max(3, b.length) * 3
148
+ }
149
+
150
+ function isAnalysisClause(text = '') {
151
+ return /(分析|研判|评估|判断|原因|趋势|风险|建议|情况|长势|预测|对比|比较|统计|为什么|是否异常|异常)/i.test(String(text))
152
+ }
153
+
154
+ function looksLikeDataInspectionClause(text = '', dataSources = []) {
155
+ const value = String(text || '')
156
+ if (!/查看|获取|看看|看一下|查询|了解|读取|展示/.test(value)) return false
157
+ if (!/数据|信息|墒情|土壤|气象|天气|湿度|水分|温度|养分|氮|磷|钾|盐分|玉米|长势|病虫害/.test(value)) return false
158
+ if (!dataSources.length) return true
159
+ return dataSources.some((source) => scoreDataSource(value, source) > 0)
160
+ }
161
+
162
+ function normalizeParamKey(value) {
163
+ return typeof value === 'object' ? JSON.stringify(value) : String(value ?? '')
164
+ }
165
+
166
+ function mergeCapabilities(functions = [], domElements = []) {
167
+ const map = new Map()
168
+
169
+ for (const item of functions) {
170
+ if (!item?.name) continue
171
+ map.set(item.name, {
172
+ name: item.name,
173
+ description: item.description || '',
174
+ params: Array.isArray(item.params) ? [...item.params] : [],
175
+ registered: true
176
+ })
177
+ }
178
+
179
+ for (const item of domElements) {
180
+ if (!item?.name) continue
181
+ const existing = map.get(item.name)
182
+ if (!existing) {
183
+ map.set(item.name, {
184
+ name: item.name,
185
+ description: item.description || '',
186
+ params: Array.isArray(item.params) ? [...item.params] : [],
187
+ registered: false
188
+ })
189
+ continue
190
+ }
191
+
192
+ if (!existing.description && item.description) {
193
+ existing.description = item.description
194
+ }
195
+
196
+ const seen = new Set(existing.params.map(normalizeParamKey))
197
+ for (const value of item.params || []) {
198
+ const key = normalizeParamKey(value)
199
+ if (!seen.has(key)) {
200
+ seen.add(key)
201
+ existing.params.push(value)
202
+ }
203
+ }
204
+ }
205
+
206
+ return [...map.values()]
207
+ }
208
+
209
+
210
+ const BUILTIN_ALIASES = [
211
+ {
212
+ name: 'refreshPage',
213
+ aliases: ['刷新页面', '刷新一下页面', '刷新当前页面', '重新加载页面', '重载页面']
214
+ },
215
+ {
216
+ name: 'goBack',
217
+ aliases: ['返回上一页', '返回上一个页面', '退回上一页', '后退', '返回上级页面', '回到上一页']
218
+ },
219
+ {
220
+ name: 'goForward',
221
+ aliases: ['前进', '前往下一页', '前往下一个页面', '进入下一页', '下一页']
222
+ },
223
+ {
224
+ name: 'openDataCenter',
225
+ aliases: ['打开数据中台', '进入数据中台', '前往数据中台', '打开数据平台', '进入数据平台']
226
+ },
227
+ {
228
+ name: 'closeDataCenter',
229
+ aliases: ['关闭数据中台', '退出数据中台', '关闭数据平台', '退出数据平台', '离开数据中台']
230
+ },
231
+ {
232
+ name: 'scrollPageTop',
233
+ aliases: ['滚动到顶部', '回到顶部', '返回顶部', '页面顶部']
234
+ },
235
+ {
236
+ name: 'scrollPageBottom',
237
+ aliases: ['滚动到底部', '到页面底部', '页面底部']
238
+ }
239
+ ]
240
+
241
+ function matchBuiltinAlias(clause = '', candidates = []) {
242
+ const text = normalize(clause)
243
+ if (!text) return null
244
+ for (const item of BUILTIN_ALIASES) {
245
+ const candidate = candidates.find((v) => v.name === item.name)
246
+ if (!candidate) continue
247
+ const alias = item.aliases.find((value) => text.includes(normalize(value)))
248
+ if (alias) return { item: candidate, value: undefined }
249
+ }
250
+ return null
251
+ }
252
+
253
+ function inferGenericAction(clause, candidates = []) {
254
+ const text = String(clause || '')
255
+ const yearMatch = text.match(/((?:19|20)\d{2})年?/)
256
+ if (yearMatch) {
257
+ // 即使目标页面尚未加载、data-ai-param 尚未出现在 DOM,
258
+ // 年份这种明确语义也可以先确定 Function;执行时再等待对应 DOM。
259
+ const yearCandidates = candidates.filter((item) => {
260
+ const corpus = normalize([item.name || '', item.description || ''].join(' '))
261
+ return /年份|年|时间/.test(corpus) || /changeyear/i.test(String(item.name || ''))
262
+ })
263
+ const preferred = yearCandidates.find((item) => /changeyear/i.test(String(item.name || ''))) || yearCandidates[0]
264
+ if (preferred) return { item: preferred, value: yearMatch[1] }
265
+ }
266
+ return null
267
+ }
268
+
269
+ function extractParamFromClause(clause, item) {
270
+ const params = Array.isArray(item.params) ? item.params : []
271
+
272
+ // 1. 优先匹配页面真实存在的 data-ai-param。
273
+ for (const value of params) {
274
+ if (valueInText(value, clause)) return value
275
+ }
276
+
277
+ // 2. 常见自然语言参数提取:2023年 / A001地块 / 40厘米等。
278
+ const year = clause.match(/((?:19|20)\d{2})年?/)
279
+ if (year && params.some((v) => String(v) === year[1])) return year[1]
280
+
281
+ const number = clause.match(/(?:切换|选择|设置|定位|打开|进入|到|为|成)\s*([\u4e00-\u9fa5A-Za-z0-9_-]{1,50})/)
282
+ if (number) {
283
+ const candidate = number[1]
284
+ const matched = params.find((v) => String(v) === candidate)
285
+ if (matched !== undefined) return matched
286
+ }
287
+
288
+ return undefined
289
+ }
290
+
291
+ function scoreCapability(clause, item) {
292
+ const text = normalize(clause)
293
+ let score = 0
294
+
295
+ const param = extractParamFromClause(clause, item)
296
+ if (param !== undefined) score += 100
297
+
298
+ if (item.name && text.includes(normalize(item.name))) score += 30
299
+
300
+ if (item.description) {
301
+ score += phraseScore(clause, item.description)
302
+ }
303
+
304
+ // 操作动词 + 描述动词的小加分。
305
+ const verbs = ['切换', '打开', '关闭', '进入', '选择', '定位', '放大', '缩小', '显示', '隐藏', '查看', '跳转']
306
+ const hasVerb = verbs.some((verb) => text.includes(verb))
307
+ if (hasVerb && item.description) score += 2
308
+
309
+ return score
310
+ }
311
+
312
+ function pickBestAction(clause, candidates = []) {
313
+ const ranked = candidates
314
+ .map((item) => ({ item, score: scoreCapability(clause, item) }))
315
+ .sort((a, b) => b.score - a.score)
316
+
317
+ const best = ranked[0]
318
+ if (!best || best.score < 4) return null
319
+
320
+ const second = ranked[1]
321
+ // 有明确参数命中时,不因为第二候选存在而误判;参数通常就是最可靠的目标信息。
322
+ const bestParam = extractParamFromClause(clause, best.item)
323
+ if (bestParam !== undefined) return { item: best.item, value: bestParam }
324
+
325
+ if (second && best.score - second.score < 2) return null
326
+ return { item: best.item, value: undefined }
327
+ }
328
+
329
+ function scoreDataSource(clause, source) {
330
+ const text = buildCompactText(clause)
331
+ const corpus = buildCompactText([
332
+ source.name || '',
333
+ source.description || '',
334
+ ...Object.entries(source.schema || {}).flatMap(([key, value]) => [key, value?.description || ''])
335
+ ].join(' '))
336
+
337
+ let score = 0
338
+ if (source.name && text.includes(buildCompactText(source.name))) score += 20
339
+
340
+ const keywords = [
341
+ '土壤', '墒情', '湿度', '水分', '温度', '氮', '磷', '钾', '盐分',
342
+ '气象', '天气', '雨量', '降雨', '风速', '光照',
343
+ '玉米', '作物', '长势', '病虫害', '虫害', '病害'
344
+ ]
345
+ for (const keyword of keywords) {
346
+ if (text.includes(keyword) && corpus.includes(keyword)) score += 5
347
+ }
348
+
349
+ score += phraseScore(clause, source.description || '')
350
+ return score
351
+ }
352
+
353
+ function pickDataSourcesForClause(clause, dataSources = []) {
354
+ const ranked = dataSources
355
+ .map((source) => ({ source, score: scoreDataSource(clause, source) }))
356
+ .sort((a, b) => b.score - a.score)
357
+
358
+ const best = ranked[0]
359
+ if (!best || best.score <= 0) return []
360
+
361
+ return ranked
362
+ .filter((item) => item.score >= Math.max(3, best.score * 0.5))
363
+ .slice(0, 3)
364
+ .map((item) => ({ name: item.source.name, params: {} }))
365
+ }
366
+
367
+ /**
368
+ * 纯 JS 解析:
369
+ * 1. 先拆分连续自然语言;
370
+ * 2. action:根据 data-ai-description + data-ai-param 精确选出 function;
371
+ * 3. analysis:仅选择 DataSource,真正分析留给 Analyzer AI;
372
+ * 4. 只有 action 无法确定时,才允许 Manager 回退到 Planner AI。
373
+ */
374
+ export function parseLocalCommands(message, manifest = {}) {
375
+ const clauses = splitLocalCommands(message, manifest)
376
+ if (!clauses.length) return { ok: false, steps: [], reason: 'empty' }
377
+
378
+ const candidates = mergeCapabilities(manifest.functions, manifest.domElements)
379
+ const dataSources = Array.isArray(manifest.dataSources) ? manifest.dataSources : []
380
+ const steps = []
381
+ let unresolved = []
382
+
383
+ for (const clause of clauses) {
384
+ if (isAnalysisClause(clause) || looksLikeDataInspectionClause(clause, dataSources)) {
385
+ const requests = pickDataSourcesForClause(clause, dataSources)
386
+ if (!requests.length) {
387
+ unresolved.push(clause)
388
+ continue
389
+ }
390
+
391
+ const analysisInstruction = String(clause)
392
+ .replace(/^(?:年|年份|时间)\s*[,,、]?\s*/i, '')
393
+ .replace(/^(?:请|请帮我|帮我|帮忙|麻烦|想看一下|想看看|想了解|查看一下|查看|看一下|看看|查询一下|查询|获取一下|获取|读取一下|读取|展示一下|展示|分析一下|分析|分析下|看下|了解一下|了解)\s*/i, '')
394
+ .trim()
395
+
396
+ steps.push({
397
+ type: 'analysis',
398
+ dataRequests: requests,
399
+ instruction: analysisInstruction || clause
400
+ })
401
+ continue
402
+ }
403
+
404
+ let matched = matchBuiltinAlias(clause, candidates)
405
+ if (!matched) matched = pickBestAction(clause, candidates)
406
+ if (!matched) matched = inferGenericAction(clause, candidates)
407
+ if (!matched) {
408
+ unresolved.push(clause)
409
+ continue
410
+ }
411
+
412
+ steps.push({
413
+ type: 'action',
414
+ function: matched.item.name,
415
+ params: matched.value !== undefined ? { value: matched.value } : {}
416
+ })
417
+ }
418
+
419
+ // 只有全部步骤都能本地确定时才算 local-only;混合场景也保留已解析步骤,交给 Manager 决定是否补一次 Planner。
420
+ return {
421
+ ok: steps.length > 0 && unresolved.length === 0,
422
+ partial: steps.length > 0 && unresolved.length > 0,
423
+ steps,
424
+ unresolved,
425
+ reason: unresolved.length ? `unresolved:${unresolved.join(' | ')}` : 'local-complete'
426
+ }
427
+ }
428
+
429
+ export function looksLikeAnalysisLocal(message = '') {
430
+ const text = String(message || '')
431
+ // 明确分析类请求,或“查看/查询/获取 + 数据类信息”也属于数据分析流程,
432
+ // 需要把 DataSource 清单带入本地解析,不能因为没有“分析”二字而漏掉。
433
+ return isAnalysisClause(text) || /(查看|获取|看看|看一下|查询|了解|读取|展示).*(数据|信息|墒情|土壤|气象|天气|湿度|水分|温度|养分|氮|磷|钾|盐分|玉米|长势|病虫害)/.test(text)
434
+ }