dsh-memory-tiers 0.3.0 → 0.5.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/ARCHITECTURE.md +230 -3
- package/client.mjs +425 -0
- package/compaction.mjs +5 -1
- package/index.mjs +35 -1
- package/package.json +15 -2
- package/settings.mjs +241 -0
- package/silicon.mjs +65 -15
package/ARCHITECTURE.md
CHANGED
|
@@ -834,10 +834,24 @@ dsh 有两套注入接口,语义完全不同:
|
|
|
834
834
|
|
|
835
835
|
系统提示词因此变成逐字节不变 → 首轮之后全量命中缓存。
|
|
836
836
|
|
|
837
|
-
**② L1
|
|
837
|
+
**② L1 默认不注入(`injectHot: false`)—— 但这条理由后来被更正了**
|
|
838
838
|
|
|
839
|
-
L1
|
|
840
|
-
|
|
839
|
+
> **⚠️ 2026-11-01 更正**:这里原先写的是「L1 每轮都变 → 注入块每轮变化 → **打掉缓存**」。
|
|
840
|
+
> 那个结论只在 `section()` 时代成立。改用 `context()` 之后,记忆块是**追加**的 user 快照,
|
|
841
|
+
> 位置在末尾,**前面历史缓存完全不受影响**。用户当场指出了这点,是对的。
|
|
842
|
+
>
|
|
843
|
+
> 真实成本只剩下「每轮多追加一条快照」,而 L1 通常只有几百到一千多字 —— 很小。
|
|
844
|
+
> 所以 `injectHot` 该不该开,取决于下面的**配套关系**,不是缓存问题。
|
|
845
|
+
|
|
846
|
+
`injectHot` 和 `compactKeepTurns` 必须配套:
|
|
847
|
+
|
|
848
|
+
| 组合 | 模型能看到最近对话吗 | 说明 |
|
|
849
|
+
|---|---|---|
|
|
850
|
+
| `keepTurns=1` + `injectHot=false` | 能(靠 dsh 历史) | 默认。快照稳定、不重复付费;代价是历史留 1 轮原文 |
|
|
851
|
+
| **`keepTurns=0` + `injectHot=true`** | 能(**只能靠 L1 快照**) | 彻底接管:dsh 历史一条不留,最近原话由 L1 提供 |
|
|
852
|
+
| `keepTurns=0` + `injectHot=false` | **不能,完全失忆** | 禁用组合 |
|
|
853
|
+
|
|
854
|
+
跨会话恢复始终靠 L2/L3(沉淀过的)+ `memory_search`(按需检索)。
|
|
841
855
|
|
|
842
856
|
**③ 节流(`refreshMinMs`,默认 5 分钟)**
|
|
843
857
|
|
|
@@ -1384,3 +1398,216 @@ link 安装的插件 **import 不到** dsh 自己的包,三个解法:
|
|
|
1384
1398
|
**`memory5.mjs` 一行都不用改**——它只依赖 `{ text, speaker, channel, at }` 这种纯数据。
|
|
1385
1399
|
`compaction.mjs` 只依赖 `session.surface.nodes` / `eventAt` / `append` 三个方法,
|
|
1386
1400
|
适配层写个小 shim 就能复用。
|
|
1401
|
+
|
|
1402
|
+
---
|
|
1403
|
+
|
|
1404
|
+
## 20. 设置页:DSH「设置 → 记忆」(2026-11-01,v0.4.0)
|
|
1405
|
+
|
|
1406
|
+
> 之前只能在 `~/.dsh/storages/memory-v5/config.json` 里手改,改错一个逗号插件就起不来。
|
|
1407
|
+
> 现在有了图形界面,12 个字段分 5 组,外加只读运行状态。
|
|
1408
|
+
|
|
1409
|
+
### 为什么手写界面,不用 schemastery
|
|
1410
|
+
|
|
1411
|
+
dsh 的自动表单要 `@deepseek-ai/schemastery` 声明 schema,**link 安装的插件 import 不到**
|
|
1412
|
+
(同 `defineTool` / `CompactionEngine` 那个坑,见 19.5)。所以走「自己开 HTTP 接口 + 自己写 React 组件」:
|
|
1413
|
+
|
|
1414
|
+
```
|
|
1415
|
+
package.json
|
|
1416
|
+
"dsh": { "client": { "platform": "web", "inject": [] } }
|
|
1417
|
+
"exports": { "./client": "./client.mjs" }
|
|
1418
|
+
|
|
1419
|
+
client.mjs 浏览器端:React section
|
|
1420
|
+
settings.mjs 服务端:/dsh-memory/api/*
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
### 客户端:三个关键点
|
|
1424
|
+
|
|
1425
|
+
```js
|
|
1426
|
+
window.__ModuleLoader__.load({
|
|
1427
|
+
id: 'dsh-memory-tiers',
|
|
1428
|
+
factory: (require) => {
|
|
1429
|
+
const React = require('react') // 宿主提供的 react
|
|
1430
|
+
const { createElement: h } = React
|
|
1431
|
+
// ...组件...
|
|
1432
|
+
exports.apply = apply
|
|
1433
|
+
return module.exports
|
|
1434
|
+
},
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
const inject = ['slots']
|
|
1438
|
+
function apply(ctx) {
|
|
1439
|
+
ctx.effect(() => ctx.slots.inject('settings.section', () =>
|
|
1440
|
+
ctx.slots.register(
|
|
1441
|
+
{ name: 'settings.section', id: 'memory-tiers', order: 7, label: () => '记忆' },
|
|
1442
|
+
MemoryTiersSection,
|
|
1443
|
+
)))
|
|
1444
|
+
}
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
- 模块不是标准 ESM,是 **`window.__ModuleLoader__.load({id, factory})`**(照抄 dsh-bili-whale)
|
|
1448
|
+
- `require('react')` 由宿主注入,**不需要打包工具**(手写 47KB 也能跑,没有 devDependencies)
|
|
1449
|
+
- CSS 通过 `<style data-dmt=…>` 注入,颜色用 `--dsw-alias-*` 变量,深浅色主题自适应
|
|
1450
|
+
|
|
1451
|
+
`order: 7` 排在养鱼(6)后面。`label()` 是函数,支持后续改文案不用重载。
|
|
1452
|
+
|
|
1453
|
+
### 服务端:一个路径只能注册一个 handler
|
|
1454
|
+
|
|
1455
|
+
```js
|
|
1456
|
+
ctx.webServer.register({ kind: 'exact', path: '/dsh-memory/api/settings', handler })
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
> ⚠️ **踩过的坑**:读和写注册成两个 handler(同一路径两次),宿主「首个匹配即终止」,
|
|
1460
|
+
> 结果 GET 200 而 POST 405。必须**合成一个 handler,内部按 `req.method` 分派**。
|
|
1461
|
+
> 这个坑 dsh-bili-whale 的源码注释里提过,我们还是踩了一遍。
|
|
1462
|
+
|
|
1463
|
+
字段写入走**白名单**(`WRITABLE` 表),前端传 `dir`、`evil` 都会被忽略——
|
|
1464
|
+
配置目录不能让前端改,否则等于开了任意路径写文件。
|
|
1465
|
+
|
|
1466
|
+
### 界面结构
|
|
1467
|
+
|
|
1468
|
+
| 分组 | 字段 |
|
|
1469
|
+
|---|---|
|
|
1470
|
+
| 运行状态(只读) | L1/L2/L3/DL3 条数、向量数、数据目录、语义检索开关 |
|
|
1471
|
+
| 模型连接 | `apiKey`(密码框)、`apiBase` |
|
|
1472
|
+
| 三层容量 | `memoryCfg.hotMaxChars` / `consolidateMaxChars` / `longMaxChars` |
|
|
1473
|
+
| 上下文注入 | `injectHot`、`refreshMinMs` |
|
|
1474
|
+
| 历史压缩 | `autoCompact`、`compactKeepTurns`、`compactArchiveChars` |
|
|
1475
|
+
| 写入捕获 | `userCaptureChars`、`toolCaptureChars` |
|
|
1476
|
+
| 底部 | `capture.log` 最后 15 行(每轮的归档与压缩记录) |
|
|
1477
|
+
|
|
1478
|
+
### 改配置需要重启
|
|
1479
|
+
|
|
1480
|
+
保存只写 `config.json`,运行中的 `mem` 实例不会重建。面板上明确写了「重启 dsh 后生效」。
|
|
1481
|
+
|
|
1482
|
+
要做热重载的话,得在保存接口里 dispose 旧 `mem` 再 `createMemory5` 一遍,
|
|
1483
|
+
但内存里的分层状态、队列、定时器都要跟着重建,风险大于收益——**现阶段就要求重启**。
|
|
1484
|
+
|
|
1485
|
+
### 验证
|
|
1486
|
+
|
|
1487
|
+
```
|
|
1488
|
+
注册的路由: /dsh-memory/api/settings:exact, /dsh-memory/api/log:exact
|
|
1489
|
+
|
|
1490
|
+
GET → HTTP 200, config: apiKey/bootMaxChars/injectHot/memoryCfg
|
|
1491
|
+
live: {counts:{L1:0,L2:0,L3:0,DL1:0,DL2:0,DL3:0}, vectors:0, semantic:true}
|
|
1492
|
+
POST → HTTP 200, applied: ["apiKey","injectHot","memoryCfg.hotMaxChars","memoryCfg.longMaxChars"]
|
|
1493
|
+
ignored: [] needRestart: true
|
|
1494
|
+
落盘正确;传 dir / evil 被白名单挡住
|
|
1495
|
+
|
|
1496
|
+
客户端: 模块加载 → 导出 apply/inject/MemoryTiersSection/FIELDS
|
|
1497
|
+
12 个字段 / 5 个分组
|
|
1498
|
+
slots.register({name:'settings.section', id:'memory-tiers', order:7, label:'记忆'})
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
### 21:00 补充:`compactKeepTurns: 0` 是受支持的显式选项(2026-11-01)
|
|
1502
|
+
|
|
1503
|
+
用户要的是「dsh 历史一条不留,全部交给记忆」。实测**能做**:
|
|
1504
|
+
|
|
1505
|
+
```
|
|
1506
|
+
第 1 轮压缩: removed=2 | surface 剩 2 条
|
|
1507
|
+
第 2 轮压缩: removed=3 | surface 剩 2 条
|
|
1508
|
+
第 3 轮压缩: removed=3 | surface 剩 2 条
|
|
1509
|
+
第 4 轮压缩: removed=3 | surface 剩 2 条
|
|
1510
|
+
|
|
1511
|
+
最终 surface:
|
|
1512
|
+
system/message: sys
|
|
1513
|
+
user/message: (此前 1 轮对话已归档到长期记忆,需要细节可以让我检索)
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
聊多少轮,dsh 侧都只剩 **system + 一条占位**。
|
|
1517
|
+
|
|
1518
|
+
**以前这段是靠巧合工作的**:`userIdx[length - 0]` 得到 `undefined`,`slice(first, undefined)`
|
|
1519
|
+
恰好等于「取到末尾」。虽然结果对,但纯属运气——已改成显式分支:
|
|
1520
|
+
|
|
1521
|
+
```js
|
|
1522
|
+
const keepStart = keepTurns === 0 ? nodes.length : userIdx[userIdx.length - keepTurns]
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
**必须同时开 `injectHot: true`**,否则模型完全看不见上一句问了什么(见第 16 节的配套表)。
|
|
1526
|
+
|
|
1527
|
+
注入块对照(真实库):
|
|
1528
|
+
|
|
1529
|
+
| 配置 | 注入块 | 构成 |
|
|
1530
|
+
|---|---|---|
|
|
1531
|
+
| `injectHot: false` | 4625 字 ≈ 3083 token | L3 4150 + L2 458 |
|
|
1532
|
+
| `injectHot: true` | 6088 字 ≈ 4059 token | L3 4150 + L2 458 + L1 1455 |
|
|
1533
|
+
|
|
1534
|
+
每轮多约 1000 token(追加新快照,全价)——相对于「dsh 历史归零」这点代价可以接受。
|
|
1535
|
+
|
|
1536
|
+
配置改动落在 `~/.dsh/storages/memory-v5/config.json`:
|
|
1537
|
+
|
|
1538
|
+
```json
|
|
1539
|
+
{ "injectHot": true, "compactKeepTurns": 0 }
|
|
1540
|
+
```
|
|
1541
|
+
|
|
1542
|
+
---
|
|
1543
|
+
|
|
1544
|
+
## 21. 模型分阶段配置(2026-11-01,v0.5.0)
|
|
1545
|
+
|
|
1546
|
+
> 背景:原来四个模型角色(向量 / 重排 / 轻任务 / 摘要)**共用一套 `apiKey` + `apiBase`**,
|
|
1547
|
+
> 设置页也没暴露,等于绑死在硅基流动上。现在每个阶段都能单独指定 `{ base, apiKey, model }`。
|
|
1548
|
+
|
|
1549
|
+
### 四个阶段
|
|
1550
|
+
|
|
1551
|
+
| 阶段 | 干什么 | 调用频率 | 默认模型 |
|
|
1552
|
+
|---|---|---|---|
|
|
1553
|
+
| `embed` | 向量化,检索和入库判重的基础 | 每条记忆 1 次 | `Qwen/Qwen3-Embedding-0.6B` |
|
|
1554
|
+
| `rerank` | 检索结果精排 | 每次检索 | `Qwen/Qwen3-Reranker-0.6B` |
|
|
1555
|
+
| `chat` | 判重、判矛盾、分类等高频小任务 | 很频繁 | `inclusionAI/Ling-mini-2.0` |
|
|
1556
|
+
| `summary` | 把一批对话压成摘要 | 低频但重 | `deepseek-ai/DeepSeek-V4.1-Flash` |
|
|
1557
|
+
|
|
1558
|
+
### 解析规则
|
|
1559
|
+
|
|
1560
|
+
```js
|
|
1561
|
+
function stage(name) {
|
|
1562
|
+
const over = cfg.models[name] || {}
|
|
1563
|
+
const base = over.base || cfg.base // 没覆盖就用顶层
|
|
1564
|
+
const apiKey = over.apiKey || cfg.apiKey
|
|
1565
|
+
const model = over.model || cfg[name + 'Model'] // 再退到默认模型名
|
|
1566
|
+
return { base, apiKey, model, chat: base + '/chat/completions', embed: …, rerank: … }
|
|
1567
|
+
}
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
**优先级:`models[stage].x` > 顶层 `cfg.x` > 该阶段默认模型名。**
|
|
1571
|
+
空字符串是「删除这条覆盖、回落到顶层」,不是「设成空值」——这样前端不用区分“没填过”和“清空了”。
|
|
1572
|
+
|
|
1573
|
+
配置示例(摘要单独走 DeepSeek 官方,其余留硅基流动):
|
|
1574
|
+
|
|
1575
|
+
```json
|
|
1576
|
+
{
|
|
1577
|
+
"apiKey": "sk-siliconflow",
|
|
1578
|
+
"apiBase": "https://api.siliconflow.cn/v1",
|
|
1579
|
+
"models": {
|
|
1580
|
+
"summary": {
|
|
1581
|
+
"base": "https://api.deepseek.com",
|
|
1582
|
+
"apiKey": "sk-deepseek",
|
|
1583
|
+
"model": "deepseek-chat"
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
```
|
|
1588
|
+
|
|
1589
|
+
### 实现要点
|
|
1590
|
+
|
|
1591
|
+
- `chat()` 多了一个 `stage` 参数,内部用该阶段的 url 和 key 发请求——**摘要能挂到另一家**
|
|
1592
|
+
- `embed` / `rerank` 各自调 `stage('embed')` / `stage('rerank')`,不再直接用全局 `cfg.apiKey`
|
|
1593
|
+
- key 检查放宽:`cfg.apiKey || 任一 models[s].apiKey`,否则只配了摘要一家时会被误判为"没配"
|
|
1594
|
+
- `silicon.resolveStage(name)` 和 `silicon.stages()` 暴露给设置页,显示**实际生效**的值(覆盖已合并)
|
|
1595
|
+
|
|
1596
|
+
### 验证
|
|
1597
|
+
|
|
1598
|
+
```
|
|
1599
|
+
顶层硅基流动 + summary 单独挂 deepseek + embed 换 bge-m3:
|
|
1600
|
+
embed {"base":"…siliconflow.cn/v1","model":"BAAI/bge-m3","hasKey":true}
|
|
1601
|
+
rerank {"base":"…siliconflow.cn/v1","model":"Qwen/Qwen3-Reranker-0.6B","hasKey":true}
|
|
1602
|
+
chat {"base":"…siliconflow.cn/v1","model":"inclusionAI/Ling-mini-2.0","hasKey":true}
|
|
1603
|
+
summary {"base":"https://api.deepseek.com","model":"deepseek-chat","hasKey":true}
|
|
1604
|
+
|
|
1605
|
+
设置页写入 → applied: ["models.summary.base","models.summary.apiKey","models.summary.model"]
|
|
1606
|
+
清空 base → 该键被删除,回落顶层(不是留一个空字符串)
|
|
1607
|
+
全空 → 抛「缺少 apiKey(顶层或某个阶段的 models[s].apiKey)」
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
### 一个提醒
|
|
1611
|
+
|
|
1612
|
+
换 `embed` 模型会导致**向量维度变化**,旧库的向量全部作废。真要换得清空 `vectors-part-*.json` 并重建,
|
|
1613
|
+
否则检索结果会乱。设置页的提示里写了这条。
|
package/client.mjs
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory-tiers —— 客户端半边:DSH「设置」里的 **记忆** section。
|
|
3
|
+
*
|
|
4
|
+
* 宿主提供的运行时:window.__ModuleLoader__.load({ id, factory }) + require('react')。
|
|
5
|
+
* 挂载点:ctx.slots.register('settings.section', { id, order, label }, Component)。
|
|
6
|
+
* 数据来源:本插件服务端的 /dsh-memory/api/*(settings.mjs)。
|
|
7
|
+
*
|
|
8
|
+
* 为什么不用 schemastery 声明 schema 让 dsh 自动生成表单:
|
|
9
|
+
* link 安装的插件 import 不到 @deepseek-ai/*(见 ARCHITECTURE 19.5),拿不到那个包。
|
|
10
|
+
* 手写反而能把「只读运行状态」「三层容量对照」「重启提示」排在一起。
|
|
11
|
+
*/
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: 'dsh-memory-tiers',
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
const module = { exports: {} }
|
|
16
|
+
const exports = module.exports
|
|
17
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
18
|
+
|
|
19
|
+
const React = require('react')
|
|
20
|
+
const { createElement: h, useCallback, useEffect, useState } = React
|
|
21
|
+
|
|
22
|
+
const API = '/dsh-memory/api'
|
|
23
|
+
const CSS_TAG = 'dsh-memory-tiers/section.css'
|
|
24
|
+
const CSS = [
|
|
25
|
+
'.dmt{max-width:760px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
|
|
26
|
+
'.dmt-title{margin:0;font-size:16px;font-weight:500;line-height:24px}',
|
|
27
|
+
'.dmt-intro{color:var(--dsw-alias-label-tertiary);margin:0 0 4px;font-size:13px;line-height:20px}',
|
|
28
|
+
'.dmt-group{margin-top:16px;font-size:13px;font-weight:500;line-height:20px}',
|
|
29
|
+
'.dmt-rows{display:flex;flex-direction:column}',
|
|
30
|
+
'.dmt-row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px 20px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap}',
|
|
31
|
+
'.dmt-row:last-child{border-bottom:none}',
|
|
32
|
+
'.dmt-row>div:first-child{flex:1;min-width:190px}',
|
|
33
|
+
'.dmt-label{font-size:13px;line-height:19px}',
|
|
34
|
+
'.dmt-hint{color:var(--dsw-alias-label-tertiary);margin-top:3px;font-size:11.5px;line-height:17px}',
|
|
35
|
+
'.dmt-input,.dmt-select{background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;color:var(--dsw-alias-label-primary);font-size:12.5px;height:28px;padding:0 8px;min-width:180px;outline:none}',
|
|
36
|
+
'.dmt-input:focus-visible{border-color:var(--dsw-alias-brand-primary)}',
|
|
37
|
+
'.dmt-input.dmt-wide{min-width:280px}',
|
|
38
|
+
'.dmt-check{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary)}',
|
|
39
|
+
'.dmt-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:16px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
|
|
40
|
+
'.dmt-btn{height:28px;padding:0 14px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-size:12.5px;cursor:pointer}',
|
|
41
|
+
'.dmt-btn:hover{border-color:var(--dsw-alias-border-l1)}',
|
|
42
|
+
'.dmt-btn.dmt-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff)}',
|
|
43
|
+
'.dmt-btn:disabled{opacity:.5;cursor:default}',
|
|
44
|
+
'.dmt-msg{font-size:12.5px;line-height:18px}',
|
|
45
|
+
'.dmt-msg.dmt-ok{color:var(--dsw-alias-label-success,#3b6d11)}',
|
|
46
|
+
'.dmt-msg.dmt-err{color:var(--dsw-alias-label-danger,#a32d2d)}',
|
|
47
|
+
'.dmt-stats{display:flex;gap:18px;flex-wrap:wrap;margin-top:8px}',
|
|
48
|
+
'.dmt-stat{min-width:92px}',
|
|
49
|
+
'.dmt-stat b{display:block;font-size:15px;font-weight:500;line-height:22px}',
|
|
50
|
+
'.dmt-stat span{color:var(--dsw-alias-label-tertiary);font-size:11.5px}',
|
|
51
|
+
'.dmt-log{margin-top:8px;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-secondary,rgba(127,127,127,.06));font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;line-height:16px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow:auto}',
|
|
52
|
+
'.dmt-mono{font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11.5px;word-break:break-all}',
|
|
53
|
+
].join('\n')
|
|
54
|
+
|
|
55
|
+
function ensureCss() {
|
|
56
|
+
if (typeof document === 'undefined') return
|
|
57
|
+
if (document.querySelector('style[data-dmt="' + CSS_TAG + '"]')) return
|
|
58
|
+
const el = document.createElement('style')
|
|
59
|
+
el.dataset.dmt = CSS_TAG
|
|
60
|
+
el.textContent = CSS
|
|
61
|
+
document.head.appendChild(el)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const getPath = (obj, path) => path.reduce((o, k) => (o == null ? undefined : o[k]), obj)
|
|
65
|
+
const setPath = (obj, path, value) => {
|
|
66
|
+
const next = { ...obj }
|
|
67
|
+
let cur = next
|
|
68
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
69
|
+
const k = path[i]
|
|
70
|
+
cur[k] = { ...(cur[k] || {}) }
|
|
71
|
+
cur = cur[k]
|
|
72
|
+
}
|
|
73
|
+
cur[path[path.length - 1]] = value
|
|
74
|
+
return next
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 字段定义:改这里就能增减配置项(服务端白名单见 settings.mjs 的 WRITABLE) ──
|
|
78
|
+
const FIELDS = [
|
|
79
|
+
{
|
|
80
|
+
group: '模型连接',
|
|
81
|
+
path: ['apiKey'],
|
|
82
|
+
label: 'API Key',
|
|
83
|
+
type: 'password',
|
|
84
|
+
wide: true,
|
|
85
|
+
hint: '硅基流动的 key。留空则用本地哈希向量——能跑,但语义检索会弱很多。改完需重启。',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
group: '模型连接',
|
|
89
|
+
path: ['apiBase'],
|
|
90
|
+
label: 'API 地址',
|
|
91
|
+
type: 'text',
|
|
92
|
+
wide: true,
|
|
93
|
+
hint: '默认 https://api.siliconflow.cn/v1。换别家兼容 OpenAI 接口的服务时改这里。',
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
group: '模型(每个阶段可分开指定,留空走默认)',
|
|
98
|
+
path: ['models', 'summary', 'model'],
|
|
99
|
+
label: '摘要模型',
|
|
100
|
+
type: 'text',
|
|
101
|
+
wide: true,
|
|
102
|
+
hint: '把一批对话压成摘要用哪个。留空 = deepseek-ai/DeepSeek-V4.1-Flash。摘要量最大,想单独走一家便宜的就填这里,并配上下面两项地址和 key。',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
group: '模型(每个阶段可分开指定,留空走默认)',
|
|
106
|
+
path: ['models', 'summary', 'base'],
|
|
107
|
+
label: '摘要 API 地址',
|
|
108
|
+
type: 'text',
|
|
109
|
+
wide: true,
|
|
110
|
+
hint: '留空 = 用上面「模型连接」里的 API 地址。例:https://api.deepseek.com',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
group: '模型(每个阶段可分开指定,留空走默认)',
|
|
114
|
+
path: ['models', 'summary', 'apiKey'],
|
|
115
|
+
label: '摘要 API Key',
|
|
116
|
+
type: 'password',
|
|
117
|
+
wide: true,
|
|
118
|
+
hint: '留空 = 用上面的 API Key。填了就表示摘要单独走另一家。',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
group: '模型(每个阶段可分开指定,留空走默认)',
|
|
122
|
+
path: ['models', 'chat', 'model'],
|
|
123
|
+
label: '轻任务模型',
|
|
124
|
+
type: 'text',
|
|
125
|
+
wide: true,
|
|
126
|
+
hint: '判重、判矛盾、分类这些高频小任务,选快而便宜的。留空 = inclusionAI/Ling-mini-2.0。',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
group: '模型(每个阶段可分开指定,留空走默认)',
|
|
130
|
+
path: ['models', 'embed', 'model'],
|
|
131
|
+
label: '向量模型',
|
|
132
|
+
type: 'text',
|
|
133
|
+
wide: true,
|
|
134
|
+
hint: '检索和入库判重都靠它。留空 = Qwen/Qwen3-Embedding-0.6B。换模型后向量维度不同,旧库要重建。',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
group: '模型(每个阶段可分开指定,留空走默认)',
|
|
138
|
+
path: ['models', 'rerank', 'model'],
|
|
139
|
+
label: '重排模型',
|
|
140
|
+
type: 'text',
|
|
141
|
+
wide: true,
|
|
142
|
+
hint: '检索结果精排。留空 = Qwen/Qwen3-Reranker-0.6B。',
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
group: '三层容量(决定每轮注入多少字)',
|
|
147
|
+
path: ['memoryCfg', 'hotMaxChars'],
|
|
148
|
+
label: 'L1 即时层',
|
|
149
|
+
type: 'number',
|
|
150
|
+
hint: '按时间保存的原文。满了取最老一半分流沉淀。默认 2000。',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
group: '三层容量(决定每轮注入多少字)',
|
|
154
|
+
path: ['memoryCfg', 'consolidateMaxChars'],
|
|
155
|
+
label: 'L2 巩固层',
|
|
156
|
+
type: 'number',
|
|
157
|
+
hint: '较重要的原话。默认 2500。',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
group: '三层容量(决定每轮注入多少字)',
|
|
161
|
+
path: ['memoryCfg', 'longMaxChars'],
|
|
162
|
+
label: 'L3 长期层',
|
|
163
|
+
type: 'number',
|
|
164
|
+
hint: '摘要。这个值直接决定每轮注入块的大小,默认 2000,调大更全但也更贵。',
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
group: '上下文注入',
|
|
169
|
+
path: ['injectHot'],
|
|
170
|
+
label: '把 L1 也注入上下文',
|
|
171
|
+
type: 'boolean',
|
|
172
|
+
hint: '默认关。当前会话的对话本来就在历史里,重复注入会让注入块每轮变化、打掉 prompt cache。',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
group: '上下文注入',
|
|
176
|
+
path: ['refreshMinMs'],
|
|
177
|
+
label: '记忆块刷新间隔(毫秒)',
|
|
178
|
+
type: 'number',
|
|
179
|
+
hint: '默认 1800000(30 分钟)。内容变了也要等过节流窗口才刷新,保护缓存。',
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
group: '历史压缩',
|
|
184
|
+
path: ['autoCompact'],
|
|
185
|
+
label: '自动压缩历史',
|
|
186
|
+
type: 'boolean',
|
|
187
|
+
hint: '每轮结束把宿主的会话历史压成一条占位,原文归档进记忆,不调用模型。',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
group: '历史压缩',
|
|
191
|
+
path: ['compactKeepTurns'],
|
|
192
|
+
label: '保留最近几轮原文',
|
|
193
|
+
type: 'number',
|
|
194
|
+
hint: '默认 1。设 0 会让模型完全看不见上一句问了什么,不建议。',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
group: '历史压缩',
|
|
198
|
+
path: ['compactArchiveChars'],
|
|
199
|
+
label: '单次归档字数上限',
|
|
200
|
+
type: 'number',
|
|
201
|
+
hint: '默认 1200。超出的部分截断收录,不会整段丢。',
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
group: '写入捕获',
|
|
206
|
+
path: ['userCaptureChars'],
|
|
207
|
+
label: '单条用户消息上限',
|
|
208
|
+
type: 'number',
|
|
209
|
+
hint: '默认 2000 字。超长消息只记前这么多。',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
group: '写入捕获',
|
|
213
|
+
path: ['toolCaptureChars'],
|
|
214
|
+
label: '工具输出上限',
|
|
215
|
+
type: 'number',
|
|
216
|
+
hint: '默认 500 字。bash 一次能吐几万字,L1 才 2000 字。',
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
const GROUPS = []
|
|
221
|
+
for (const f of FIELDS) if (!GROUPS.includes(f.group)) GROUPS.push(f.group)
|
|
222
|
+
|
|
223
|
+
async function api(path, init) {
|
|
224
|
+
const res = await fetch(API + path, {
|
|
225
|
+
credentials: 'same-origin',
|
|
226
|
+
headers: { 'content-type': 'application/json' },
|
|
227
|
+
...init,
|
|
228
|
+
})
|
|
229
|
+
const text = await res.text()
|
|
230
|
+
let data
|
|
231
|
+
try { data = JSON.parse(text) } catch { data = { ok: false, error: text.slice(0, 200) } }
|
|
232
|
+
if (!res.ok || data.ok === false) throw new Error(data.error || 'HTTP ' + res.status)
|
|
233
|
+
return data
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function Field({ field, value, onChange }) {
|
|
237
|
+
const kind = field.type
|
|
238
|
+
const id = 'dmt-' + field.path.join('-')
|
|
239
|
+
let control
|
|
240
|
+
if (kind === 'boolean') {
|
|
241
|
+
control = h('input', {
|
|
242
|
+
id,
|
|
243
|
+
className: 'dmt-check',
|
|
244
|
+
type: 'checkbox',
|
|
245
|
+
checked: value === true,
|
|
246
|
+
onChange: (e) => onChange(e.target.checked),
|
|
247
|
+
})
|
|
248
|
+
} else {
|
|
249
|
+
control = h('input', {
|
|
250
|
+
id,
|
|
251
|
+
className: 'dmt-input' + (field.wide ? ' dmt-wide' : ''),
|
|
252
|
+
type: kind === 'number' ? 'number' : kind === 'password' ? 'password' : 'text',
|
|
253
|
+
value: value === undefined || value === null ? '' : String(value),
|
|
254
|
+
placeholder: kind === 'password' ? '留空则用本地哈希向量' : '',
|
|
255
|
+
onChange: (e) => onChange(kind === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value),
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
return h('div', { className: 'dmt-row' },
|
|
259
|
+
h('div', null,
|
|
260
|
+
h('label', { className: 'dmt-label', htmlFor: id }, field.label),
|
|
261
|
+
field.hint ? h('div', { className: 'dmt-hint' }, field.hint) : null,
|
|
262
|
+
),
|
|
263
|
+
control,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function MemoryTiersSection() {
|
|
268
|
+
const [cfg, setCfg] = useState(null)
|
|
269
|
+
const [live, setLive] = useState(null)
|
|
270
|
+
const [logs, setLogs] = useState([])
|
|
271
|
+
const [busy, setBusy] = useState(false)
|
|
272
|
+
const [msg, setMsg] = useState(null)
|
|
273
|
+
const [err, setErr] = useState(null)
|
|
274
|
+
|
|
275
|
+
const load = useCallback(async () => {
|
|
276
|
+
try {
|
|
277
|
+
const d = await api('/settings')
|
|
278
|
+
setCfg(d.config || {})
|
|
279
|
+
setLive(d.live || null)
|
|
280
|
+
const l = await api('/log').catch(() => ({ lines: [] }))
|
|
281
|
+
setLogs(l.lines || [])
|
|
282
|
+
setErr(null)
|
|
283
|
+
} catch (e) {
|
|
284
|
+
setErr(String(e.message || e))
|
|
285
|
+
}
|
|
286
|
+
}, [])
|
|
287
|
+
|
|
288
|
+
useEffect(() => { ensureCss(); load() }, [load])
|
|
289
|
+
|
|
290
|
+
const setField = (path, value) => {
|
|
291
|
+
setCfg((cur) => setPath(cur || {}, path, value))
|
|
292
|
+
setMsg(null)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const save = async () => {
|
|
296
|
+
setBusy(true)
|
|
297
|
+
setMsg(null)
|
|
298
|
+
setErr(null)
|
|
299
|
+
try {
|
|
300
|
+
const r = await api('/settings', { method: 'POST', body: JSON.stringify({ config: cfg }) })
|
|
301
|
+
const applied = r.applied?.length ?? 0
|
|
302
|
+
setMsg(applied > 0
|
|
303
|
+
? '已保存 ' + applied + ' 项。重启 dsh 后生效。'
|
|
304
|
+
: '没有可保存的改动。')
|
|
305
|
+
await load()
|
|
306
|
+
} catch (e) {
|
|
307
|
+
setErr('保存失败:' + String(e.message || e))
|
|
308
|
+
} finally {
|
|
309
|
+
setBusy(false)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (err && !cfg) {
|
|
314
|
+
return h('div', { className: 'dmt' },
|
|
315
|
+
h('h3', { className: 'dmt-title' }, '记忆'),
|
|
316
|
+
h('div', { className: 'dmt-msg dmt-err' }, '读取配置失败:' + err),
|
|
317
|
+
h('div', { className: 'dmt-actions' }, h('button', { className: 'dmt-btn', onClick: load }, '重试')),
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!cfg) return h('div', { className: 'dmt' }, h('div', { className: 'dmt-hint' }, '加载中…'))
|
|
322
|
+
|
|
323
|
+
const c = live?.counts || {}
|
|
324
|
+
|
|
325
|
+
return h('div', { className: 'dmt' },
|
|
326
|
+
h('h3', { className: 'dmt-title' }, '记忆'),
|
|
327
|
+
h('p', { className: 'dmt-intro' },
|
|
328
|
+
'六层记忆 + 零 LLM 历史压缩。同一会话里记忆负责「记得住」,压缩负责「上下文不涨」。改完需要重启 dsh 生效。'),
|
|
329
|
+
|
|
330
|
+
// ── 运行状态(只读) ──
|
|
331
|
+
h('div', { className: 'dmt-group' }, '运行状态'),
|
|
332
|
+
h('div', { className: 'dmt-stats' },
|
|
333
|
+
h('div', { className: 'dmt-stat' }, h('b', null, String(c.L1 ?? '-')), h('span', null, 'L1 即时')),
|
|
334
|
+
h('div', { className: 'dmt-stat' }, h('b', null, String(c.L2 ?? '-')), h('span', null, 'L2 巩固')),
|
|
335
|
+
h('div', { className: 'dmt-stat' }, h('b', null, String(c.L3 ?? '-')), h('span', null, 'L3 长期')),
|
|
336
|
+
h('div', { className: 'dmt-stat' }, h('b', null, String(c.DL3 ?? '-')), h('span', null, 'DL3 摘要库')),
|
|
337
|
+
h('div', { className: 'dmt-stat' }, h('b', null, String(live?.vectors ?? '-')), h('span', null, '向量')),
|
|
338
|
+
),
|
|
339
|
+
h('div', { className: 'dmt-row' },
|
|
340
|
+
h('div', null,
|
|
341
|
+
h('div', { className: 'dmt-label' }, '数据目录'),
|
|
342
|
+
h('div', { className: 'dmt-hint' }, '整个目录是自包含的,拷走即可备份或迁移;用环境变量 MEMORY_TIERS_DIR 可换位置。'),
|
|
343
|
+
),
|
|
344
|
+
h('div', { className: 'dmt-mono' }, live?.dir || '-'),
|
|
345
|
+
),
|
|
346
|
+
h('div', { className: 'dmt-row' },
|
|
347
|
+
h('div', null,
|
|
348
|
+
h('div', { className: 'dmt-label' }, '语义检索'),
|
|
349
|
+
h('div', { className: 'dmt-hint' }, live?.semantic ? '已接真模型' : '未配置 key,当前用本地哈希向量,关键词匹配为主'),
|
|
350
|
+
),
|
|
351
|
+
h('div', { className: 'dmt-msg ' + (live?.semantic ? 'dmt-ok' : '') }, live?.semantic ? '开' : '关'),
|
|
352
|
+
),
|
|
353
|
+
live?.models
|
|
354
|
+
? h('div', { className: 'dmt-row' },
|
|
355
|
+
h('div', null,
|
|
356
|
+
h('div', { className: 'dmt-label' }, '实际生效的模型'),
|
|
357
|
+
h('div', { className: 'dmt-hint' }, '留空的项已回落到顶层配置或默认值。改完需重启才会重新解析。'),
|
|
358
|
+
),
|
|
359
|
+
h('div', {
|
|
360
|
+
className: 'dmt-mono',
|
|
361
|
+
style: { whiteSpace: 'pre-line', textAlign: 'right' },
|
|
362
|
+
}, Object.entries(live.models)
|
|
363
|
+
.map(([k, v]) => k + ': ' + (v.model || '?') + ' @ ' + String(v.base || '').replace(/^https?:\/\//, ''))
|
|
364
|
+
.join('\n')),
|
|
365
|
+
)
|
|
366
|
+
: null,
|
|
367
|
+
|
|
368
|
+
// ── 配置字段 ──
|
|
369
|
+
...GROUPS.map((g) =>
|
|
370
|
+
h('div', { key: g },
|
|
371
|
+
h('div', { className: 'dmt-group' }, g),
|
|
372
|
+
h('div', { className: 'dmt-rows' },
|
|
373
|
+
FIELDS.filter((f) => f.group === g).map((f) =>
|
|
374
|
+
h(Field, {
|
|
375
|
+
key: f.path.join('.'),
|
|
376
|
+
field: f,
|
|
377
|
+
value: getPath(cfg, f.path),
|
|
378
|
+
onChange: (v) => setField(f.path, v),
|
|
379
|
+
}),
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
),
|
|
383
|
+
),
|
|
384
|
+
|
|
385
|
+
// ── 操作 ──
|
|
386
|
+
h('div', { className: 'dmt-actions' },
|
|
387
|
+
h('button', { className: 'dmt-btn dmt-primary', onClick: save, disabled: busy }, busy ? '保存中…' : '保存'),
|
|
388
|
+
h('button', { className: 'dmt-btn', onClick: load, disabled: busy }, '重新读取'),
|
|
389
|
+
msg ? h('span', { className: 'dmt-msg dmt-ok' }, msg) : null,
|
|
390
|
+
err ? h('span', { className: 'dmt-msg dmt-err' }, err) : null,
|
|
391
|
+
),
|
|
392
|
+
|
|
393
|
+
// ── 最近审计 ──
|
|
394
|
+
h('div', { className: 'dmt-group' }, '最近归档 / 压缩(capture.log 末尾)'),
|
|
395
|
+
logs.length
|
|
396
|
+
? h('div', { className: 'dmt-log' }, logs.join('\n'))
|
|
397
|
+
: h('div', { className: 'dmt-hint' }, '还没有记录。聊几轮后这里会出现每轮的归档与压缩行。'),
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const inject = ['slots']
|
|
402
|
+
const NS = 'memory-tiers'
|
|
403
|
+
const NAMES = { nav: '记忆' }
|
|
404
|
+
|
|
405
|
+
function apply(ctx) {
|
|
406
|
+
ctx.effect(
|
|
407
|
+
() =>
|
|
408
|
+
ctx.slots.inject('settings.section', () =>
|
|
409
|
+
ctx.slots.register(
|
|
410
|
+
{ name: 'settings.section', id: NS, order: 7, label: () => NAMES.nav },
|
|
411
|
+
MemoryTiersSection,
|
|
412
|
+
),
|
|
413
|
+
'memory-tiers: settings section',
|
|
414
|
+
),
|
|
415
|
+
'memory-tiers: settings section',
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
exports.apply = apply
|
|
420
|
+
exports.inject = inject
|
|
421
|
+
exports.MemoryTiersSection = MemoryTiersSection
|
|
422
|
+
exports.FIELDS = FIELDS
|
|
423
|
+
return module.exports
|
|
424
|
+
},
|
|
425
|
+
})
|
package/compaction.mjs
CHANGED
|
@@ -93,7 +93,11 @@ export async function compactSurface(session, mem, opts = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// 选区 = [first, keepStart),keepStart 是「保留的第一轮」的 user 位置
|
|
96
|
-
|
|
96
|
+
// ★ keepTurns === 0 是**受支持的显式选项**:整段历史全压成占位,dsh 侧一条原文不留。
|
|
97
|
+
// 以前靠 `userIdx[length - 0] === undefined` + `slice(first, undefined)` 恰好取到末尾
|
|
98
|
+
// 才"能用",纯属巧合;这里写死,免得以后重构踩空。
|
|
99
|
+
// ⚠️ 用 0 时必须同时开 injectHot(注入 L1),否则模型完全看不见刚才聊了什么。
|
|
100
|
+
const keepStart = keepTurns === 0 ? nodes.length : userIdx[userIdx.length - keepTurns]
|
|
97
101
|
if (keepStart <= first) {
|
|
98
102
|
return { compacted: false, removed: 0, archived: 0, reason: 'nothing-to-compact' }
|
|
99
103
|
}
|
package/index.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from 'node:path'
|
|
|
20
20
|
import { createMemory5, LAYER, DEFAULTS as MEM_DEFAULTS } from './memory5.mjs'
|
|
21
21
|
import { createSilicon, keyFromAstrbot } from './silicon.mjs'
|
|
22
22
|
import { compactSurface } from './compaction.mjs'
|
|
23
|
+
import { installSettingsApi } from './settings.mjs'
|
|
23
24
|
|
|
24
25
|
export const name = 'memory-tiers'
|
|
25
26
|
export const inject = ['tools']
|
|
@@ -211,6 +212,10 @@ export function apply(ctx, config = {}) {
|
|
|
211
212
|
compactKeepTurns: 1,
|
|
212
213
|
compactArchiveChars: 1200,
|
|
213
214
|
memoryCfg: { longMaxChars: 4000 },
|
|
215
|
+
// 每个模型阶段可单独覆盖 { base, apiKey, model };留空继承顶层 apiKey/apiBase。
|
|
216
|
+
// 例:想让它摘要走 DeepSeek 官方而不走硅基流动
|
|
217
|
+
// models: { summary: { base: 'https://api.deepseek.com', apiKey: 'sk-...', model: 'deepseek-chat' } }
|
|
218
|
+
models: {},
|
|
214
219
|
_说明: [
|
|
215
220
|
'apiKey 填硅基流动的 key(也可以设环境变量 SILICONFLOW_API_KEY);',
|
|
216
221
|
'留空则用本地哈希向量,语义检索会变弱。改完重启 dsh 生效。',
|
|
@@ -231,7 +236,13 @@ export function apply(ctx, config = {}) {
|
|
|
231
236
|
let silicon = null
|
|
232
237
|
if (auth) {
|
|
233
238
|
try {
|
|
234
|
-
silicon = createSilicon({
|
|
239
|
+
silicon = createSilicon({
|
|
240
|
+
apiKey: auth.apiKey,
|
|
241
|
+
base: auth.base,
|
|
242
|
+
budgetTokens: cfg.budgetTokens,
|
|
243
|
+
// 每个阶段可单独指定 { base, apiKey, model };留空继承上面的 apiKey/base
|
|
244
|
+
models: cfg.models || {},
|
|
245
|
+
})
|
|
235
246
|
} catch (err) {
|
|
236
247
|
log.warn?.('[memory-tiers] silicon 初始化失败:%s', err?.message ?? err)
|
|
237
248
|
}
|
|
@@ -549,6 +560,29 @@ export function apply(ctx, config = {}) {
|
|
|
549
560
|
toolCount, !!silicon, cfg.autoCapture !== false)
|
|
550
561
|
})
|
|
551
562
|
|
|
563
|
+
// ── 设置页的服务端接口:读写 config.json(前端见 client.mjs) ──
|
|
564
|
+
// webServer 可能比插件晚就绪,用 inject 等它,而不是直接读 ctx.webServer。
|
|
565
|
+
ctx.inject(['webServer'], (c) => {
|
|
566
|
+
const off = installSettingsApi(c, {
|
|
567
|
+
dir,
|
|
568
|
+
getCfg: () => cfg,
|
|
569
|
+
log,
|
|
570
|
+
mem,
|
|
571
|
+
getModels: () => {
|
|
572
|
+
if (!silicon?.resolveStage) return null
|
|
573
|
+
const out = {}
|
|
574
|
+
for (const st of silicon.stages()) out[st] = silicon.resolveStage(st)
|
|
575
|
+
return out
|
|
576
|
+
},
|
|
577
|
+
})
|
|
578
|
+
if (typeof off === 'function') {
|
|
579
|
+
try { c.effect(() => off, 'memory-tiers: settings api') } catch { /* 老版本没有 effect 也不影响接口可用 */ }
|
|
580
|
+
} else {
|
|
581
|
+
log.warn?.('[memory-tiers] webServer 不可用,设置页接口没挂上')
|
|
582
|
+
}
|
|
583
|
+
log.info?.('[memory-tiers] 设置页接口已挂:/dsh-memory/api/settings')
|
|
584
|
+
})
|
|
585
|
+
|
|
552
586
|
// ── 斜杠命令:手动查看/维护入口 ──
|
|
553
587
|
ctx.inject(['commands'], (c) => {
|
|
554
588
|
c.commands.register({
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-memory-tiers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "六层记忆插件:L1/L2/L3 活跃层 + DL1/DL2/DL3 深层,自动捕获写入、摘要沉淀、按需召回,并接管历史压缩(零 LLM)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"keywords": [
|
|
8
|
+
"keywords": [
|
|
9
|
+
"dsh",
|
|
10
|
+
"dsh-plugin",
|
|
11
|
+
"memory",
|
|
12
|
+
"compaction",
|
|
13
|
+
"context"
|
|
14
|
+
],
|
|
9
15
|
"repository": {
|
|
10
16
|
"type": "git",
|
|
11
17
|
"url": "git+https://github.com/1129742588/dsh-memory-tiers.git"
|
|
@@ -17,10 +23,15 @@
|
|
|
17
23
|
"dsh": {
|
|
18
24
|
"bundle": {
|
|
19
25
|
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"inject": [],
|
|
29
|
+
"platform": "web"
|
|
20
30
|
}
|
|
21
31
|
},
|
|
22
32
|
"exports": {
|
|
23
33
|
".": "./index.mjs",
|
|
34
|
+
"./client": "./client.mjs",
|
|
24
35
|
"./compaction.mjs": "./compaction.mjs",
|
|
25
36
|
"./memory5.mjs": "./memory5.mjs",
|
|
26
37
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
@@ -28,6 +39,8 @@
|
|
|
28
39
|
},
|
|
29
40
|
"files": [
|
|
30
41
|
"index.mjs",
|
|
42
|
+
"client.mjs",
|
|
43
|
+
"settings.mjs",
|
|
31
44
|
"compaction.mjs",
|
|
32
45
|
"memory5.mjs",
|
|
33
46
|
"silicon.mjs",
|
package/settings.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页的服务端半边:读写 config.json + 暴露运行状态。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要自己写 HTTP 接口而不是用 schemastery 声明 schema:
|
|
5
|
+
* link 安装的插件 import 不到 @deepseek-ai/*,拿不到 dsh 的 schema 包(见 ARCHITECTURE 19.5)。
|
|
6
|
+
* 走 webServer + 自己的 React 组件反而更可控——字段说明、分组、只读状态都能自己排版。
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { isIP } from 'node:net'
|
|
11
|
+
|
|
12
|
+
/** 可写字段白名单:只管运行参数,dir 和 _说明 不让前端改 */
|
|
13
|
+
const WRITABLE = {
|
|
14
|
+
apiKey: 'string',
|
|
15
|
+
apiBase: 'string',
|
|
16
|
+
bootMaxChars: 'number',
|
|
17
|
+
injectHot: 'boolean',
|
|
18
|
+
refreshMinMs: 'number',
|
|
19
|
+
autoCompact: 'boolean',
|
|
20
|
+
compactKeepTurns: 'number',
|
|
21
|
+
compactArchiveChars: 'number',
|
|
22
|
+
userCaptureChars: 'number',
|
|
23
|
+
toolCaptureChars: 'number',
|
|
24
|
+
budgetTokens: 'number',
|
|
25
|
+
flushEveryMs: 'number',
|
|
26
|
+
sweepEveryMs: 'number',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 三层容量在 memoryCfg 里,单独处理 */
|
|
30
|
+
const MEMORY_CFG_WRITABLE = {
|
|
31
|
+
hotMaxChars: 'number',
|
|
32
|
+
consolidateMaxChars: 'number',
|
|
33
|
+
longMaxChars: 'number',
|
|
34
|
+
dl2MaxChars: 'number',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 每个模型阶段可单独覆盖的连接参数;留空字符串 = 清除覆盖、回落到顶层 */
|
|
38
|
+
const STAGES = ['embed', 'rerank', 'chat', 'summary']
|
|
39
|
+
const STAGE_FIELDS = { base: 'string', apiKey: 'string', model: 'string' }
|
|
40
|
+
|
|
41
|
+
function json(res, code, obj) {
|
|
42
|
+
const body = JSON.stringify(obj)
|
|
43
|
+
res.statusCode = code
|
|
44
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
45
|
+
res.setHeader('cache-control', 'no-store')
|
|
46
|
+
res.end(body)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readBody(req) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let buf = ''
|
|
52
|
+
req.on('data', (c) => {
|
|
53
|
+
buf += c
|
|
54
|
+
if (buf.length > 1 << 20) reject(new Error('body too large'))
|
|
55
|
+
})
|
|
56
|
+
req.on('end', () => resolve(buf))
|
|
57
|
+
req.on('error', reject)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 只信任本机回环;远程连接交给 dsh 的 connection 服务判定 */
|
|
62
|
+
function isTrustedReq(ctx, req) {
|
|
63
|
+
const addr = req.socket?.remoteAddress || ''
|
|
64
|
+
const bare = addr.replace(/^::ffff:/, '')
|
|
65
|
+
if (isIP(bare) && (bare === '127.0.0.1' || bare === '::1')) return true
|
|
66
|
+
try {
|
|
67
|
+
const conn = ctx.get?.('connection') || ctx.connection
|
|
68
|
+
if (conn && typeof conn.isTrusted === 'function') return conn.isTrusted(req) === true
|
|
69
|
+
} catch { /* 栅栏不可用按不信任处理 */ }
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function coerce(kind, value) {
|
|
74
|
+
if (kind === 'boolean') {
|
|
75
|
+
if (typeof value === 'boolean') return value
|
|
76
|
+
if (value === 'true') return true
|
|
77
|
+
if (value === 'false') return false
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
if (kind === 'number') {
|
|
81
|
+
const n = Number(value)
|
|
82
|
+
return Number.isFinite(n) ? n : undefined
|
|
83
|
+
}
|
|
84
|
+
if (kind === 'string') return typeof value === 'string' ? value : undefined
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 挂上 /dsh-memory/api/*。
|
|
90
|
+
* @returns {() => void} disposer;webServer 不可用时返回 null
|
|
91
|
+
*/
|
|
92
|
+
export function installSettingsApi(ctx, opts) {
|
|
93
|
+
const { dir, getCfg, log, mem, getModels } = opts
|
|
94
|
+
const webServer = ctx.webServer
|
|
95
|
+
if (!webServer?.register) return null
|
|
96
|
+
|
|
97
|
+
const cfgPath = join(dir, 'config.json')
|
|
98
|
+
const disposers = []
|
|
99
|
+
|
|
100
|
+
const readCfgFile = () => {
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(cfgPath)) return {}
|
|
103
|
+
return JSON.parse(readFileSync(cfgPath, 'utf8'))
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log?.warn?.('[memory-tiers] 读 config.json 失败:%s', err?.message ?? err)
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function route(path, handler) {
|
|
111
|
+
const off = webServer.register({
|
|
112
|
+
kind: 'exact',
|
|
113
|
+
path,
|
|
114
|
+
handler: async (req, res) => {
|
|
115
|
+
if (!isTrustedReq(ctx, req)) { json(res, 403, { ok: false, error: '不受信任的访问来源' }); return }
|
|
116
|
+
try { await handler(req, res) } catch (err) {
|
|
117
|
+
log?.warn?.('[memory-tiers] 设置接口出错:%s', err?.message ?? err)
|
|
118
|
+
json(res, 500, { ok: false, error: String(err?.message ?? err) })
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
if (typeof off === 'function') disposers.push(off)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── settings:读和写必须**合成一个 handler** ──
|
|
126
|
+
// 踩过的坑:同一路径注册两次,若宿主「首个匹配即终止」,后注册的永远收不到请求。
|
|
127
|
+
// 实测表现是 GET 正常、POST 405(或反之),排查半天。
|
|
128
|
+
route('/dsh-memory/api/settings', async (req, res) => {
|
|
129
|
+
if (req.method === 'GET') {
|
|
130
|
+
const file = readCfgFile()
|
|
131
|
+
const live = getCfg() || {}
|
|
132
|
+
const stats = (() => {
|
|
133
|
+
try { return mem?.stats?.() ?? null } catch { return null }
|
|
134
|
+
})()
|
|
135
|
+
json(res, 200, {
|
|
136
|
+
ok: true,
|
|
137
|
+
config: {
|
|
138
|
+
...file,
|
|
139
|
+
memoryCfg: { ...(file.memoryCfg || {}) },
|
|
140
|
+
},
|
|
141
|
+
live: {
|
|
142
|
+
dir,
|
|
143
|
+
counts: stats?.count ?? null,
|
|
144
|
+
vectors: stats?.vectors ?? null,
|
|
145
|
+
semantic: !!(live.apiKey || process.env.SILICONFLOW_API_KEY ||
|
|
146
|
+
Object.values(live.models || {}).some((m) => m?.apiKey)),
|
|
147
|
+
autoCompact: live.autoCompact !== false,
|
|
148
|
+
// 各阶段最终解析成什么(覆盖已合并),设置页拿来显示"实际生效"
|
|
149
|
+
models: (() => {
|
|
150
|
+
try { return getModels?.() ?? null } catch { return null }
|
|
151
|
+
})(),
|
|
152
|
+
},
|
|
153
|
+
configPath: cfgPath,
|
|
154
|
+
})
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: '只接受 GET / POST' }); return }
|
|
159
|
+
|
|
160
|
+
const raw = await readBody(req)
|
|
161
|
+
let body
|
|
162
|
+
try { body = JSON.parse(raw || '{}') } catch { json(res, 400, { ok: false, error: 'JSON 解析失败' }); return }
|
|
163
|
+
|
|
164
|
+
// 兼容 { config: {...} } 和直接 {...} 两种写法(踩过:只认一种会「保存成功但没变」)
|
|
165
|
+
const incoming = (body && typeof body.config === 'object' && body.config) || body || {}
|
|
166
|
+
|
|
167
|
+
const cur = readCfgFile()
|
|
168
|
+
const next = { ...cur }
|
|
169
|
+
const applied = []
|
|
170
|
+
const ignored = []
|
|
171
|
+
|
|
172
|
+
for (const [k, kind] of Object.entries(WRITABLE)) {
|
|
173
|
+
if (!(k in incoming)) continue
|
|
174
|
+
const v = coerce(kind, incoming[k])
|
|
175
|
+
if (v === undefined) { ignored.push(k); continue }
|
|
176
|
+
next[k] = v
|
|
177
|
+
applied.push(k)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (incoming.memoryCfg && typeof incoming.memoryCfg === 'object') {
|
|
181
|
+
const mc = { ...(cur.memoryCfg || {}) }
|
|
182
|
+
for (const [k, kind] of Object.entries(MEMORY_CFG_WRITABLE)) {
|
|
183
|
+
if (!(k in incoming.memoryCfg)) continue
|
|
184
|
+
const v = coerce(kind, incoming.memoryCfg[k])
|
|
185
|
+
if (v === undefined) { ignored.push('memoryCfg.' + k); continue }
|
|
186
|
+
mc[k] = v
|
|
187
|
+
applied.push('memoryCfg.' + k)
|
|
188
|
+
}
|
|
189
|
+
next.memoryCfg = mc
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 每阶段模型覆盖 ──
|
|
193
|
+
// 语义:填了就覆盖,留空(空字符串)= 删掉这条覆盖,回落到顶层的 apiKey/apiBase 和默认模型名。
|
|
194
|
+
// 这样「全走一家」和「摘要单独挂别家」都能表达,不用区分"没填过"和"清空了"。
|
|
195
|
+
if (incoming.models && typeof incoming.models === 'object') {
|
|
196
|
+
const mm = { ...(cur.models || {}) }
|
|
197
|
+
for (const st of STAGES) {
|
|
198
|
+
const src = incoming.models[st]
|
|
199
|
+
if (!src || typeof src !== 'object') continue
|
|
200
|
+
const dst = { ...(mm[st] || {}) }
|
|
201
|
+
for (const [k, kind] of Object.entries(STAGE_FIELDS)) {
|
|
202
|
+
if (!(k in src)) continue
|
|
203
|
+
const v = coerce(kind, src[k])
|
|
204
|
+
if (v === undefined) { ignored.push(`models.${st}.${k}`); continue }
|
|
205
|
+
if (v === '') delete dst[k]
|
|
206
|
+
else dst[k] = v
|
|
207
|
+
applied.push(`models.${st}.${k}`)
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(dst).length) mm[st] = dst
|
|
210
|
+
else delete mm[st]
|
|
211
|
+
}
|
|
212
|
+
next.models = mm
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
mkdirSync(dir, { recursive: true })
|
|
217
|
+
writeFileSync(cfgPath, JSON.stringify(next, null, 2), 'utf8')
|
|
218
|
+
} catch (err) {
|
|
219
|
+
json(res, 500, { ok: false, error: '写入失败:' + String(err?.message ?? err) })
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
log?.info?.('[memory-tiers] 设置已更新:%s 项,重启 dsh 生效', applied.length)
|
|
223
|
+
json(res, 200, { ok: true, applied, ignored, needRestart: true })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// ── 最近归档/压缩审计(设置页底部展示) ──
|
|
227
|
+
route('/dsh-memory/api/log', async (req, res) => {
|
|
228
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false }); return }
|
|
229
|
+
let lines = []
|
|
230
|
+
try {
|
|
231
|
+
const p = join(dir, 'capture.log')
|
|
232
|
+
if (existsSync(p)) {
|
|
233
|
+
const all = readFileSync(p, 'utf8').trim().split('\n')
|
|
234
|
+
lines = all.slice(-15)
|
|
235
|
+
}
|
|
236
|
+
} catch { /* 读不到就当空 */ }
|
|
237
|
+
json(res, 200, { ok: true, lines })
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return () => { for (const off of disposers) { try { off() } catch { /* 已卸 */ } } }
|
|
241
|
+
}
|
package/silicon.mjs
CHANGED
|
@@ -319,7 +319,34 @@ const RESULT_PATTERNS = /(跑通|改完|部署|提交|补完|删掉|清理|扩
|
|
|
319
319
|
|
|
320
320
|
export function createSilicon(options = {}) {
|
|
321
321
|
const cfg = { ...DEFAULTS, ...options }
|
|
322
|
-
|
|
322
|
+
// models 段允许每个阶段单独覆盖 { base, apiKey, model },留空则继承顶层的 base/apiKey
|
|
323
|
+
if (!cfg.models || typeof cfg.models !== 'object') cfg.models = {}
|
|
324
|
+
|
|
325
|
+
const STAGES = ['embed', 'rerank', 'chat', 'summary']
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 解析某个阶段的最终连接参数。
|
|
329
|
+
* 优先级:models[stage].xxx > 顶层 cfg.xxx > 该阶段的默认模型名。
|
|
330
|
+
* 这样既能"全部走硅基流动",也能"摘要单独挂 DeepSeek 官方、向量走本地服务"。
|
|
331
|
+
*/
|
|
332
|
+
function stage(name) {
|
|
333
|
+
const over = cfg.models[name] || {}
|
|
334
|
+
const base = String(over.base || cfg.base || '').replace(/\/+$/, '')
|
|
335
|
+
const apiKey = over.apiKey || cfg.apiKey || ''
|
|
336
|
+
const model = over.model || cfg[name + 'Model'] || ''
|
|
337
|
+
return {
|
|
338
|
+
base,
|
|
339
|
+
apiKey,
|
|
340
|
+
model,
|
|
341
|
+
chat: base + '/chat/completions',
|
|
342
|
+
embed: base + '/embeddings',
|
|
343
|
+
rerank: base + '/rerank',
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 只要任意一个阶段拿到了 key 就能跑;全部为空才认为没配(由调用方降级到本地哈希向量)
|
|
348
|
+
const anyKey = cfg.apiKey || STAGES.some((s) => cfg.models?.[s]?.apiKey)
|
|
349
|
+
if (!anyKey) throw new Error('[silicon] 缺少 apiKey(顶层或某个阶段的 models[s].apiKey)')
|
|
323
350
|
|
|
324
351
|
const chatUrl = `${cfg.base.replace(/\/+$/, '')}/chat/completions`
|
|
325
352
|
const embedUrl = `${cfg.base.replace(/\/+$/, '')}/embeddings`
|
|
@@ -404,21 +431,26 @@ export function createSilicon(options = {}) {
|
|
|
404
431
|
const {
|
|
405
432
|
maxTokens = 400,
|
|
406
433
|
temperature = 0,
|
|
407
|
-
|
|
434
|
+
stage: stageName = 'chat',
|
|
435
|
+
model,
|
|
408
436
|
timeout = cfg.timeoutMs,
|
|
409
437
|
thinking = false,
|
|
410
438
|
} = opts
|
|
411
439
|
|
|
440
|
+
const st = stage(stageName)
|
|
441
|
+
const useModel = model || st.model
|
|
442
|
+
|
|
412
443
|
const body = {
|
|
413
|
-
model,
|
|
444
|
+
model: useModel,
|
|
414
445
|
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
|
|
415
446
|
max_tokens: maxTokens,
|
|
416
447
|
temperature,
|
|
417
448
|
}
|
|
418
449
|
if (thinking === false) body.enable_thinking = false
|
|
419
450
|
|
|
420
|
-
|
|
421
|
-
|
|
451
|
+
// ★ 用该阶段自己的 url / key:摘要可以挂到另一家 provider 上
|
|
452
|
+
const d = await post(st.chat, st.apiKey, body, timeout, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
|
|
453
|
+
note(stageName, d.usage?.total_tokens, useModel)
|
|
422
454
|
return (d.choices?.[0]?.message?.content || '').trim()
|
|
423
455
|
}
|
|
424
456
|
|
|
@@ -429,7 +461,8 @@ export function createSilicon(options = {}) {
|
|
|
429
461
|
const list = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t || '').trim()).filter(Boolean)
|
|
430
462
|
if (!list.length) return null
|
|
431
463
|
try {
|
|
432
|
-
const
|
|
464
|
+
const es = stage('embed')
|
|
465
|
+
const d = await post(es.embed, es.apiKey, { model: es.model, input: list }, cfg.timeoutMs, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
|
|
433
466
|
note('embed', d.usage?.total_tokens || d.meta?.tokens?.input_tokens)
|
|
434
467
|
const vecs = (d.data || []).map((x) => x.embedding)
|
|
435
468
|
return vecs.length === list.length ? vecs : null
|
|
@@ -443,9 +476,10 @@ export function createSilicon(options = {}) {
|
|
|
443
476
|
const list = (docs || []).map((t) => String(t || ''))
|
|
444
477
|
if (!list.length) return null
|
|
445
478
|
try {
|
|
446
|
-
const
|
|
479
|
+
const rs = stage('rerank')
|
|
480
|
+
const body = { model: rs.model, query: String(query || ''), documents: list }
|
|
447
481
|
if (topN > 0) body.top_n = Math.min(topN, list.length)
|
|
448
|
-
const d = await post(
|
|
482
|
+
const d = await post(rs.rerank, rs.apiKey, body, cfg.timeoutMs, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
|
|
449
483
|
note('rerank', d.meta?.tokens?.input_tokens)
|
|
450
484
|
return (d.results || []).map((x) => ({ index: x.index, score: Number(x.relevance_score) }))
|
|
451
485
|
} catch {
|
|
@@ -524,7 +558,7 @@ export function createSilicon(options = {}) {
|
|
|
524
558
|
// 再留 250 给"多写一行"的余量。上限 4000 是模型的安全区。
|
|
525
559
|
maxTokens: Math.min(250 + 10 * list.length, 4000),
|
|
526
560
|
temperature: 0.2,
|
|
527
|
-
|
|
561
|
+
stage: 'summary',
|
|
528
562
|
timeout: cfg.summaryTimeoutMs,
|
|
529
563
|
// ★ 必须显式关掉推理。
|
|
530
564
|
// 这里原来传的是 null,而 chat() 里只处理 `thinking === false`,
|
|
@@ -656,7 +690,7 @@ export function createSilicon(options = {}) {
|
|
|
656
690
|
const out = await chat(sys, user, {
|
|
657
691
|
maxTokens: Math.min(300 + 12 * list.length, 4000),
|
|
658
692
|
temperature: 0.2,
|
|
659
|
-
|
|
693
|
+
stage: 'summary',
|
|
660
694
|
timeout: cfg.summaryTimeoutMs,
|
|
661
695
|
thinking: false,
|
|
662
696
|
})
|
|
@@ -869,15 +903,31 @@ export function createSilicon(options = {}) {
|
|
|
869
903
|
ms: Date.now() - t0,
|
|
870
904
|
dim: v[0].length,
|
|
871
905
|
models: {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
906
|
+
// 显示**实际生效**的值(阶段覆盖已合并),而不是原始配置
|
|
907
|
+
embed: stage('embed').model,
|
|
908
|
+
rerank: stage('rerank').model,
|
|
909
|
+
chat: stage('chat').model,
|
|
910
|
+
summary: stage('summary').model,
|
|
911
|
+
},
|
|
912
|
+
endpoints: {
|
|
913
|
+
embed: stage('embed').base,
|
|
914
|
+
rerank: stage('rerank').base,
|
|
915
|
+
chat: stage('chat').base,
|
|
916
|
+
summary: stage('summary').base,
|
|
876
917
|
},
|
|
877
918
|
}
|
|
878
919
|
}
|
|
879
920
|
|
|
880
|
-
return {
|
|
921
|
+
return {
|
|
922
|
+
embed, rerank, summarize, summarizeWithSources, classify, classifyBatch, sameEvent, relation, ping, cos,
|
|
923
|
+
config: cfg, usage, resetUsage, budget,
|
|
924
|
+
/** 给设置页/诊断用:某阶段最终解析成什么 */
|
|
925
|
+
resolveStage: (name) => {
|
|
926
|
+
const st = stage(name)
|
|
927
|
+
return { base: st.base, model: st.model, hasKey: !!st.apiKey }
|
|
928
|
+
},
|
|
929
|
+
stages: () => STAGES,
|
|
930
|
+
}
|
|
881
931
|
}
|
|
882
932
|
|
|
883
933
|
/** 从 AstrBot 的 cmd_config.json 里取 siliconflow 的 key,省去手抄 */
|