@heixui/dsh-session-suite 0.3.3 → 0.4.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/README.md +22 -0
- package/README.zh.md +24 -3
- package/client.js +67 -4
- package/cordis.patch.yml +27 -0
- package/lib/embeddings.js +239 -0
- package/lib/memory-engine.js +492 -13
- package/lib/memory-host.js +199 -43
- package/lib/retrieval.js +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -345,6 +345,28 @@ anything, and it never deletes real content.
|
|
|
345
345
|
as symptom → root cause → fix. Read it before changing the snapshot, compaction,
|
|
346
346
|
memory, or export paths.
|
|
347
347
|
|
|
348
|
+
`docs/memory-architecture.md` maps the memory engine against the standard
|
|
349
|
+
two-layer agent-memory design, with measured numbers rather than claims — including
|
|
350
|
+
the parts that are deliberately not built and why.
|
|
351
|
+
|
|
352
|
+
## What changed in 0.4.0
|
|
353
|
+
|
|
354
|
+
The memory engine was reorganised; the session features are unchanged.
|
|
355
|
+
|
|
356
|
+
| Area | Before | Now |
|
|
357
|
+
|---|---|---|
|
|
358
|
+
| Duplicate memories | One topic occupied 7 of 46 entries | Maintenance is idempotent (verified across 6 passes); existing duplicates merged into cold storage |
|
|
359
|
+
| Resident layer | 2 flat entries mixed into the "relevant" block | Up to 5 topic cards, in their own labelled section |
|
|
360
|
+
| Episodic memory | Absent | `kind: 'episode'` with time, context, and outcome; decays twice as fast |
|
|
361
|
+
| Dense reranking | Implemented but off, weight unverified | **Optional, off by default**, weight calibrated by measurement (MRR 0.535 → 0.760 on 8 paraphrased queries, recall unchanged) |
|
|
362
|
+
| Privacy | Scope only | `public` / `internal` / `secret`; credentials auto-detected; `secret` never injected or exported |
|
|
363
|
+
| Implicit queue | Keyword gate dropped 7 of 10 valid candidates | Gate removed; an empty queue now states why it is empty |
|
|
364
|
+
|
|
365
|
+
**Embedding is optional and adds no download by default.** Lexical retrieval alone is
|
|
366
|
+
the default behaviour; installing a model adds cross-lingual and paraphrase recall.
|
|
367
|
+
See `docs/memory-architecture.md` for the measured effect and
|
|
368
|
+
`tools/download-embedding.mjs` for the deliberate, explicit install path.
|
|
369
|
+
|
|
348
370
|
## Limits
|
|
349
371
|
|
|
350
372
|
* Rewinding does not rewrite the durable log; it forks at the target message and archives the old session. That
|
package/README.zh.md
CHANGED
|
@@ -157,13 +157,34 @@ tools/ 自测脚本与预清理脚本(不随插件打包)
|
|
|
157
157
|
docs/ 需求与 API 笔记
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
## 0.4.0 改了什么
|
|
161
|
+
|
|
162
|
+
本次重构的是记忆引擎,会话相关功能未变。
|
|
163
|
+
|
|
164
|
+
| 方面 | 改前 | 改后 |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| 重复记忆 | 46 条里同一主题占 7 条 | 维护幂等(连续 6 轮验证);存量重复已合并进冷库 |
|
|
167
|
+
| 常驻层 | 2 条扁平记忆,混在「相关」区块里 | 最多 5 张主题卡片,独立分区标注 |
|
|
168
|
+
| 情景记忆 | 没有 | `kind: 'episode'`,带发生时间、情境、结果;衰减加倍 |
|
|
169
|
+
| dense 重排 | 实现了但默认关,权重未验证 | **可选,默认关**;权重经实测校准(8 条改写查询 MRR 0.535→0.760,recall 不变) |
|
|
170
|
+
| 隐私分级 | 只有 scope | `public`/`internal`/`secret`;凭据自动识别;secret 永不注入、永不导出 |
|
|
171
|
+
| 隐式队列 | 关键词闸门误杀 7/10 合法候选 | 闸门已移除;空队列会说明原因 |
|
|
172
|
+
|
|
173
|
+
**embedding 是可选增强,默认不下载任何东西。** 不装模型就是纯词法检索,功能完整;装了才有跨语言与改写召回。
|
|
174
|
+
实测效果与安装方式见 `docs/memory-architecture.md` 和 `tools/download-embedding.mjs`。
|
|
175
|
+
|
|
160
176
|
## 测试
|
|
161
177
|
|
|
162
|
-
|
|
178
|
+
全部自测脚本都在临时目录里跑,不会碰真实的 DSH 数据。
|
|
163
179
|
|
|
164
180
|
```bash
|
|
165
|
-
node tools/selftest.mjs
|
|
166
|
-
node tools/selftest-
|
|
181
|
+
node tools/selftest.mjs # 快照 / 忽略规则 / 恢复 / 淘汰 / 记忆
|
|
182
|
+
node tools/selftest-memory-dedupe.mjs # 维护幂等,重复不再生成
|
|
183
|
+
node tools/selftest-resident-cards.mjs # 常驻卡片分组与预算
|
|
184
|
+
node tools/selftest-episode-memory.mjs # 情景记忆类型与衰减
|
|
185
|
+
node tools/selftest-memory-privacy.mjs # 隐私分级与导出过滤
|
|
186
|
+
node tools/selftest-implicit-queue.mjs # 隐式候选不再被误杀
|
|
187
|
+
node tools/probe-dense-rerank.mjs # dense 重排的实测对比(需要模型)
|
|
167
188
|
```
|
|
168
189
|
|
|
169
190
|
## 已知边界
|
package/client.js
CHANGED
|
@@ -14,8 +14,24 @@
|
|
|
14
14
|
* tokens and interaction patterns.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The module id must be the package name.
|
|
19
|
+
*
|
|
20
|
+
* `dsh-client-modules` builds each manifest entry's id from the package it
|
|
21
|
+
* resolved, and the browser-side loader de-duplicates registrations by that same
|
|
22
|
+
* id. When this file registered itself as `dsh-session-suite` while the package
|
|
23
|
+
* resolved as `@heixui/dsh-session-suite`, those were two different ids for one
|
|
24
|
+
* bundle: the combo script registered the factory once, the graph row imported
|
|
25
|
+
* it again, and the second registration threw
|
|
26
|
+
*
|
|
27
|
+
* client-modules: duplicate factory registration for "dsh-session-suite"
|
|
28
|
+
*
|
|
29
|
+
* which aborted the whole web boot (the app refused to start). The shipped
|
|
30
|
+
* plugins that work all register under their package name — dsh-connect-trae
|
|
31
|
+
* uses "dsh-connect-trae", whale-pet uses "<package>#<submodule>".
|
|
32
|
+
*/
|
|
17
33
|
window.__ModuleLoader__.load({
|
|
18
|
-
id: 'dsh-session-suite',
|
|
34
|
+
id: '@heixui/dsh-session-suite',
|
|
19
35
|
factory(require) {
|
|
20
36
|
const React = require('react')
|
|
21
37
|
const h = React.createElement
|
|
@@ -162,6 +178,7 @@ window.__ModuleLoader__.load({
|
|
|
162
178
|
'memory.workspacePicker': 'Workspace',
|
|
163
179
|
'memory.workspaceNone': 'No workspace detected',
|
|
164
180
|
'memory.rule': 'Rule',
|
|
181
|
+
'memory.episode': 'Episode',
|
|
165
182
|
'memory.summary': 'Summary',
|
|
166
183
|
'memory.frozen': 'Frozen',
|
|
167
184
|
'memory.archived': 'Archived',
|
|
@@ -194,6 +211,11 @@ window.__ModuleLoader__.load({
|
|
|
194
211
|
'memory.remove': 'Delete',
|
|
195
212
|
'memory.pendingTitle': 'Suggested memories',
|
|
196
213
|
'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
|
|
214
|
+
'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
|
|
215
|
+
'memory.extractionUnknown': 'no attempt recorded since this Host started',
|
|
216
|
+
'memory.extractionTried': 'tried {n} memory model(s)',
|
|
217
|
+
'memory.extractionQueued': '{n} queued',
|
|
218
|
+
'memory.extractionRejected': '{n} rejected as unusable',
|
|
197
219
|
'memory.accept': 'Keep',
|
|
198
220
|
'memory.reject': 'Discard',
|
|
199
221
|
'memory.accepted': 'Kept',
|
|
@@ -341,6 +363,7 @@ window.__ModuleLoader__.load({
|
|
|
341
363
|
'memory.workspacePicker': '工作区',
|
|
342
364
|
'memory.workspaceNone': '未检测到工作区',
|
|
343
365
|
'memory.rule': '规则',
|
|
366
|
+
'memory.episode': '经历',
|
|
344
367
|
'memory.summary': '摘要',
|
|
345
368
|
'memory.frozen': '已冻结',
|
|
346
369
|
'memory.archived': '已归档',
|
|
@@ -373,6 +396,11 @@ window.__ModuleLoader__.load({
|
|
|
373
396
|
'memory.remove': '删除',
|
|
374
397
|
'memory.pendingTitle': '待确认的记忆',
|
|
375
398
|
'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
|
|
399
|
+
'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
|
|
400
|
+
'memory.extractionUnknown': '本次运行还没有执行过提取',
|
|
401
|
+
'memory.extractionTried': '试过 {n} 个记忆模型',
|
|
402
|
+
'memory.extractionQueued': '入队 {n} 条',
|
|
403
|
+
'memory.extractionRejected': '剔除 {n} 条',
|
|
376
404
|
'memory.accept': '保留',
|
|
377
405
|
'memory.reject': '丢弃',
|
|
378
406
|
'memory.accepted': '已保留',
|
|
@@ -2555,10 +2583,14 @@ window.__ModuleLoader__.load({
|
|
|
2555
2583
|
h('span', { className: 'dss-row-meta' }, t('memory.filterCount', { n: (items ?? []).length })),
|
|
2556
2584
|
)
|
|
2557
2585
|
|
|
2586
|
+
// An empty queue used to render nothing at all, which is indistinguishable
|
|
2587
|
+
// from a broken feature: the user cannot tell "the model judged this turn
|
|
2588
|
+
// unremarkable" from "every memory model failed" or "a filter ate the
|
|
2589
|
+
// result". When there is nothing pending, say why.
|
|
2590
|
+
const extraction = status?.extraction
|
|
2558
2591
|
const pendingBlock =
|
|
2559
|
-
pending.length
|
|
2560
|
-
?
|
|
2561
|
-
: h(
|
|
2592
|
+
pending.length > 0
|
|
2593
|
+
? h(
|
|
2562
2594
|
'div',
|
|
2563
2595
|
{ className: 'dss-mem-pending' },
|
|
2564
2596
|
h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
|
|
@@ -2600,6 +2632,33 @@ window.__ModuleLoader__.load({
|
|
|
2600
2632
|
),
|
|
2601
2633
|
),
|
|
2602
2634
|
)
|
|
2635
|
+
: extraction
|
|
2636
|
+
? h(
|
|
2637
|
+
'div',
|
|
2638
|
+
{ className: 'dss-mem-pending' },
|
|
2639
|
+
h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
|
|
2640
|
+
h(
|
|
2641
|
+
'div',
|
|
2642
|
+
{ className: 'dss-mem-pending-hint' },
|
|
2643
|
+
t('memory.extractionIdle', {
|
|
2644
|
+
reason: extraction.reason || t('memory.extractionUnknown'),
|
|
2645
|
+
}),
|
|
2646
|
+
),
|
|
2647
|
+
extraction.candidates?.length
|
|
2648
|
+
? h(
|
|
2649
|
+
'div',
|
|
2650
|
+
{ className: 'dss-row-meta' },
|
|
2651
|
+
h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
|
|
2652
|
+
extraction.queued !== undefined
|
|
2653
|
+
? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
|
|
2654
|
+
: null,
|
|
2655
|
+
extraction.rejected !== undefined
|
|
2656
|
+
? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
|
|
2657
|
+
: null,
|
|
2658
|
+
)
|
|
2659
|
+
: null,
|
|
2660
|
+
)
|
|
2661
|
+
: null
|
|
2603
2662
|
|
|
2604
2663
|
const listBlock =
|
|
2605
2664
|
visible.length === 0
|
|
@@ -2620,6 +2679,10 @@ window.__ModuleLoader__.load({
|
|
|
2620
2679
|
{ className: 'dss-row-meta' },
|
|
2621
2680
|
h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
|
|
2622
2681
|
entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
|
|
2682
|
+
// An episode is a dated event, not a truth. Labelling it
|
|
2683
|
+
// separately is what lets the user tell "this happened once"
|
|
2684
|
+
// from "this is how things are".
|
|
2685
|
+
entry.kind === 'episode' ? h('span', { className: 'dss-badge' }, t('memory.episode')) : null,
|
|
2623
2686
|
entry.summary ? h('span', { className: 'dss-badge' }, t('memory.summary')) : null,
|
|
2624
2687
|
entry.frozen ? h('span', { className: 'dss-badge dss-locked' }, t('memory.frozen')) : null,
|
|
2625
2688
|
entry.archived ? h('span', { className: 'dss-badge dss-temp' }, t('memory.archived')) : null,
|
package/cordis.patch.yml
CHANGED
|
@@ -69,6 +69,33 @@
|
|
|
69
69
|
bm25K1: 1.5
|
|
70
70
|
bm25B: 0.75
|
|
71
71
|
rrfK: 60
|
|
72
|
+
# ---------- optional dense reranking (stage 2) ----------
|
|
73
|
+
# Lexical retrieval cannot bridge a paraphrase that shares no character
|
|
74
|
+
# (checkpoint vs 快照). A dense model can, so it reorders the candidates
|
|
75
|
+
# the lexical stage already found. It is OPTIONAL and OFF by default:
|
|
76
|
+
# nothing is downloaded, and recall works exactly as before when disabled.
|
|
77
|
+
#
|
|
78
|
+
# To enable, install a model deliberately and point modelDir at it:
|
|
79
|
+
#
|
|
80
|
+
# embedding:
|
|
81
|
+
# enabled: true
|
|
82
|
+
# provider: local # local | remote
|
|
83
|
+
# modelDir: D:\AI agent cache\models\bge-base-zh-v1.5
|
|
84
|
+
#
|
|
85
|
+
# Or use an OpenAI-compatible endpoint instead of local files:
|
|
86
|
+
#
|
|
87
|
+
# embedding:
|
|
88
|
+
# enabled: true
|
|
89
|
+
# provider: remote
|
|
90
|
+
# baseUrl: https://api.siliconflow.cn/v1
|
|
91
|
+
# apiKey: sk-...
|
|
92
|
+
# model: BAAI/bge-m3
|
|
93
|
+
#
|
|
94
|
+
# Either way, a failure degrades to lexical ranking; it never breaks recall.
|
|
95
|
+
embedding:
|
|
96
|
+
enabled: true
|
|
97
|
+
provider: local
|
|
98
|
+
modelDir: D:\AI agent cache\dsh\models\bge-base-zh-v1.5
|
|
72
99
|
# Similarity above which a new memory reinforces an existing one.
|
|
73
100
|
duplicateSimilarity: 0.82
|
|
74
101
|
# Similarity above which two memories may join one cluster.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional dense embeddings for memory reranking.
|
|
3
|
+
*
|
|
4
|
+
* Design constraints, in the order they shaped this file:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Never download anything implicitly.** A memory plugin that silently pulls
|
|
7
|
+
* several hundred megabytes onto someone's disk is not acceptable; the model
|
|
8
|
+
* is installed deliberately, and this module only loads what is already there.
|
|
9
|
+
* 2. **Never break recall.** Embedding is an optimisation. Any failure — no model,
|
|
10
|
+
* unreadable file, out-of-memory, a provider that changed shape — must degrade
|
|
11
|
+
* to "no reranking", never to an exception on the recall path.
|
|
12
|
+
* 3. **The model is swappable.** Two providers are supported without changing the
|
|
13
|
+
* engine: a local transformers.js pipeline, and any OpenAI-compatible
|
|
14
|
+
* /embeddings endpoint (Zhipu, SiliconFlow, and friends). The engine only ever
|
|
15
|
+
* sees `{ vector }` or `undefined`.
|
|
16
|
+
*
|
|
17
|
+
* The default is the local pipeline, because recall runs on the prompt path and
|
|
18
|
+
* must not depend on a network round trip: a busy or rate-limited provider would
|
|
19
|
+
* turn every step into a slow one.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A provider that never produces vectors.
|
|
27
|
+
*
|
|
28
|
+
* This is the default. Reranking then costs nothing and changes nothing, which
|
|
29
|
+
* is the correct behaviour for a library with no model installed.
|
|
30
|
+
*/
|
|
31
|
+
export const DISABLED_PROVIDER = {
|
|
32
|
+
name: 'disabled',
|
|
33
|
+
available: false,
|
|
34
|
+
async embed() {
|
|
35
|
+
return undefined
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A local transformers.js pipeline.
|
|
41
|
+
*
|
|
42
|
+
* The model directory is supplied by the caller; nothing is downloaded here. If
|
|
43
|
+
* the directory or the runtime is missing, `available` stays false and the
|
|
44
|
+
* provider behaves like the disabled one.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} input provider input
|
|
47
|
+
* @param {string} input.modelDir absolute directory holding the model files
|
|
48
|
+
* @param {string} [input.pipeline] transformers.js pipeline name
|
|
49
|
+
* @param {number} [input.batchSize] texts per inference call
|
|
50
|
+
* @returns {Promise<object>} provider
|
|
51
|
+
*/
|
|
52
|
+
export async function createLocalProvider({ modelDir, pipeline = 'feature-extraction', batchSize = 16, runtime }) {
|
|
53
|
+
if (typeof modelDir !== 'string' || !modelDir) return DISABLED_PROVIDER
|
|
54
|
+
// Point at the model directory itself; transformers.js accepts a path and will
|
|
55
|
+
// not reach the network when the files are present.
|
|
56
|
+
if (!fs.existsSync(modelDir)) return DISABLED_PROVIDER
|
|
57
|
+
|
|
58
|
+
let transformers = runtime
|
|
59
|
+
if (!transformers) {
|
|
60
|
+
try {
|
|
61
|
+
// Resolved at runtime so a profile without the package still loads the bundle.
|
|
62
|
+
transformers = await import('@xenova/transformers')
|
|
63
|
+
} catch {
|
|
64
|
+
try {
|
|
65
|
+
transformers = await import('@huggingface/transformers')
|
|
66
|
+
} catch {
|
|
67
|
+
return DISABLED_PROVIDER
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Turn OFF remote fetching before creating the pipeline.
|
|
73
|
+
//
|
|
74
|
+
// Passing a directory to pipeline() is NOT enough: when any expected file is
|
|
75
|
+
// missing, transformers.js silently falls back to downloading the model from
|
|
76
|
+
// Hugging Face. On a network where that host is unreachable the failure surfaces
|
|
77
|
+
// as `fetch failed` / CONNECT_TIMEOUT, which looks like a network problem rather
|
|
78
|
+
// than a missing-file problem — and worse, on a reachable network the plugin would
|
|
79
|
+
// pull hundreds of megabytes onto a user's disk without asking. allowRemoteModels
|
|
80
|
+
// = false turns a missing file into a clear, immediate error instead.
|
|
81
|
+
//
|
|
82
|
+
// transformers.js resolves a model by joining localModelPath with the model id, so
|
|
83
|
+
// localModelPath must be the PARENT directory and the id the folder name. An
|
|
84
|
+
// absolute path passed as the id defeats that join and triggers the remote lookup.
|
|
85
|
+
let modelId = modelDir
|
|
86
|
+
try {
|
|
87
|
+
if (transformers.env) {
|
|
88
|
+
transformers.env.allowRemoteModels = false
|
|
89
|
+
transformers.env.allowLocalModels = true
|
|
90
|
+
const parent = path.dirname(modelDir)
|
|
91
|
+
transformers.env.localModelPath = parent
|
|
92
|
+
modelId = path.basename(modelDir)
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
/* an older runtime without env is still usable with an explicit path */
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const extractor = await transformers.pipeline(pipeline, modelId)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
name: 'local',
|
|
102
|
+
available: true,
|
|
103
|
+
modelDir,
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} text input
|
|
106
|
+
* @returns {Promise<number[] | undefined>} embedding
|
|
107
|
+
*/
|
|
108
|
+
async embed(text) {
|
|
109
|
+
const value = String(text ?? '').trim()
|
|
110
|
+
if (!value) return undefined
|
|
111
|
+
try {
|
|
112
|
+
const output = await extractor(value, { pooling: 'mean', normalize: true })
|
|
113
|
+
// transformers.js returns a Tensor; `data` is the flat Float32Array.
|
|
114
|
+
const data = output?.data ?? output?.[0]?.data ?? output
|
|
115
|
+
if (!data) return undefined
|
|
116
|
+
return Array.from(data, Number)
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* Embed many texts in one call. Falls back to per-text calls when the
|
|
123
|
+
* runtime does not support batches, which is slower but still correct.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} texts inputs
|
|
126
|
+
* @returns {Promise<Array<number[] | undefined>>} embeddings
|
|
127
|
+
*/
|
|
128
|
+
async embedMany(texts) {
|
|
129
|
+
const list = Array.isArray(texts) ? texts : []
|
|
130
|
+
if (list.length === 0) return []
|
|
131
|
+
try {
|
|
132
|
+
const output = await extractor(list, { pooling: 'mean', normalize: true })
|
|
133
|
+
const rows = output?.tolist?.() ?? output?.data ?? undefined
|
|
134
|
+
if (Array.isArray(rows) && rows.length === list.length && Array.isArray(rows[0])) {
|
|
135
|
+
return rows.map(row => Array.from(row, Number))
|
|
136
|
+
}
|
|
137
|
+
if (rows && rows.length === list.length * (rows[0]?.length ?? 0)) {
|
|
138
|
+
// Flat buffer: split into equal rows.
|
|
139
|
+
const width = rows.length / list.length
|
|
140
|
+
const out = []
|
|
141
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
142
|
+
out.push(Array.from(rows.slice(index * width, (index + 1) * width), Number))
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
/* fall through to per-item calls */
|
|
148
|
+
}
|
|
149
|
+
const out = []
|
|
150
|
+
for (const text of list.slice(0, batchSize * 8)) out.push(await this.embed(text))
|
|
151
|
+
return out
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* An OpenAI-compatible /embeddings endpoint.
|
|
158
|
+
*
|
|
159
|
+
* Used when the user prefers no local files. Every failure returns undefined, so
|
|
160
|
+
* a flaky endpoint degrades to lexical ranking rather than failing recall.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} input provider input
|
|
163
|
+
* @param {string} input.baseUrl endpoint base, e.g. https://api.siliconflow.cn/v1
|
|
164
|
+
* @param {string} input.apiKey bearer token
|
|
165
|
+
* @param {string} input.model model id
|
|
166
|
+
* @param {number} [input.timeoutMs] request timeout
|
|
167
|
+
* @returns {object} provider
|
|
168
|
+
*/
|
|
169
|
+
export function createRemoteProvider({ baseUrl, apiKey, model, timeoutMs = 5000 }) {
|
|
170
|
+
if (typeof baseUrl !== 'string' || !baseUrl || !apiKey || !model) return DISABLED_PROVIDER
|
|
171
|
+
const endpoint = `${baseUrl.replace(/\/+$/, '')}/embeddings`
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
name: 'remote',
|
|
175
|
+
available: true,
|
|
176
|
+
model,
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} text input
|
|
179
|
+
* @returns {Promise<number[] | undefined>} embedding
|
|
180
|
+
*/
|
|
181
|
+
async embed(text) {
|
|
182
|
+
const value = String(text ?? '').trim()
|
|
183
|
+
if (!value) return undefined
|
|
184
|
+
const controller = new AbortController()
|
|
185
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch(endpoint, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
190
|
+
body: JSON.stringify({ model, input: value }),
|
|
191
|
+
signal: controller.signal,
|
|
192
|
+
})
|
|
193
|
+
if (!response.ok) return undefined
|
|
194
|
+
const payload = await response.json()
|
|
195
|
+
const vector = payload?.data?.[0]?.embedding
|
|
196
|
+
return Array.isArray(vector) ? vector.map(Number) : undefined
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timer)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build the provider described by plugin config, or the disabled one.
|
|
208
|
+
*
|
|
209
|
+
* Configuration is read defensively: a half-written block yields the disabled
|
|
210
|
+
* provider rather than a crash, because this runs during plugin installation.
|
|
211
|
+
*
|
|
212
|
+
* @param {any} config plugin config
|
|
213
|
+
* @returns {Promise<object>} provider
|
|
214
|
+
*/
|
|
215
|
+
export async function createEmbeddingProvider(config) {
|
|
216
|
+
const block = config?.embedding
|
|
217
|
+
if (!block || typeof block !== 'object' || block.enabled === false) return DISABLED_PROVIDER
|
|
218
|
+
|
|
219
|
+
if (block.provider === 'remote') {
|
|
220
|
+
return createRemoteProvider({
|
|
221
|
+
baseUrl: block.baseUrl,
|
|
222
|
+
apiKey: block.apiKey,
|
|
223
|
+
model: block.model,
|
|
224
|
+
timeoutMs: Number(block.timeoutMs) || 5000,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const modelDir = typeof block.modelDir === 'string' && block.modelDir
|
|
229
|
+
? block.modelDir
|
|
230
|
+
: path.join(config?.root ?? '', 'models', String(block.model ?? 'bge-base-zh-v1.5'))
|
|
231
|
+
try {
|
|
232
|
+
return await createLocalProvider({
|
|
233
|
+
modelDir,
|
|
234
|
+
pipeline: typeof block.pipeline === 'string' ? block.pipeline : 'feature-extraction',
|
|
235
|
+
})
|
|
236
|
+
} catch {
|
|
237
|
+
return DISABLED_PROVIDER
|
|
238
|
+
}
|
|
239
|
+
}
|