dsh-fish-soul 0.15.4 → 0.15.6
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 +15 -2
- package/lib/browse.js +5 -5
- package/lib/chat-archive.js +18 -0
- package/lib/client.js +73 -12
- package/lib/client.soul.js +139 -27
- package/lib/config-schema.js +4 -0
- package/lib/db.js +25 -0
- package/lib/importer.js +6 -4
- package/lib/index.js +22 -6
- package/lib/memory-bulk.js +4 -3
- package/lib/panel.css +35 -8
- package/lib/retrieval.js +16 -3
- package/lib/routes-soul.js +69 -10
- package/lib/routes.js +6 -6
- package/lib/soul-memory.js +9 -4
- package/lib/storage.js +71 -0
- package/lib/weekly.js +56 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
|
|
4
4
|
|
|
5
|
-
DSH 插件(单角色 `feiyu`)。版本 **0.15.
|
|
5
|
+
DSH 插件(单角色 `feiyu`)。版本 **0.15.6**。
|
|
6
6
|
|
|
7
7
|
QQ 交流群:**1125962189**
|
|
8
8
|
|
|
@@ -106,4 +106,17 @@ npm run eval # 用真模型做人格评测(规则 + 模型评审,需要
|
|
|
106
106
|
|
|
107
107
|
## 许可
|
|
108
108
|
|
|
109
|
-
MIT
|
|
109
|
+
MIT
|
|
110
|
+
### 周记(可选)
|
|
111
|
+
|
|
112
|
+
在「记忆检视」的「周记」中,可手动生成或开启自动生成。默认关闭;开启或手动运行前会提示:该功能使用「写日记」配置的模型,记忆内容(包括私密内容)可能发送至远程模型并产生调用费用。每次最多处理 20 条已结束自然周的记忆,按人物、会话和隐私范围分批;模型失败不会更改原记忆。
|
|
113
|
+
|
|
114
|
+
周记是带来源的派生记忆,不会删除原文,也不等于释放磁盘空间。可在记忆检视中查看来源;原记忆内容、范围、隐私或状态变更后,关联周记自动失效且不能恢复,已处理原文不会自动重新生成。数据库备份保留周记及其来源关系;普通 JSON 记忆导入不恢复派生周记,请使用数据库备份恢复。
|
|
115
|
+
|
|
116
|
+
### 存档与容量管理
|
|
117
|
+
|
|
118
|
+
「记忆检视 → 记忆存档」可搜索当前库的全部记忆记录(包括已归档和失效记录)。每周空闲时默认创建完整数据库压缩快照;可暂停、手动生成、查看和删除。生成先通过 SQLite 一致性快照,再 gzip 并解压校验 SHA-256,成功后才完成;周记整理前也会先确保近期存在校验通过的存档。恢复先解压并运行数据库校验,确认后在下次启动时替换库,替换前会保留当前库的恢复前备份。快照保留历史,不会自动删除;在线记忆也不会因压缩被删除,所以压缩存档**不等于减少在线数据库占用**。
|
|
119
|
+
|
|
120
|
+
容量界面显示数据库、存档、备份、聊天文本的占用和磁盘剩余空间,并允许设置告警阈值;上下文指标仅按字符估计记忆片段的预算占用,连续接近上限时在页面标题栏提示,不代表模型完整上下文 Token 用量。预算不够放下某条匹配记忆时,会尝试后续较短的匹配结果,而不是直接停止。磁盘不足时停止创建存档。存档和备份位于本地,未加密,可能包含私密记忆;删除在线记录不会删除先前的存档或备份,请单独管理。
|
|
121
|
+
|
|
122
|
+
「聊天文本存档」默认关闭,必须明确输入要保存的会话 ID(如 `private:123`、`group:456`)并设置保留天数。仅保存启用后插件实际接入并写入聊天日志的文本,不包含附件、未被接入的消息或历史消息,不能保证平台聊天全量;历史请另行导入。文本单独存于未加密的本地数据库,可分会话查看或删除,过期文本在插件运行期间清理;离线期间不会清理。缩短期限可能立即删除过期文本;用户自行制作的备份和复制件需要另行删除。
|
package/lib/browse.js
CHANGED
|
@@ -84,15 +84,15 @@ export class BiliBrowser {
|
|
|
84
84
|
like: br.like === true,
|
|
85
85
|
share: br.share !== false,
|
|
86
86
|
favorite: br.favorite === true,
|
|
87
|
-
favoritePerDay: num(br.favoritePerDay, 2, 0,
|
|
87
|
+
favoritePerDay: num(br.favoritePerDay, 2, 0, 30),
|
|
88
88
|
commentReply: br.commentReply === true,
|
|
89
|
-
commentReplyPerDay: num(br.commentReplyPerDay, 1, 0,
|
|
90
|
-
hotComments: num(br.hotComments, 6, 0,
|
|
89
|
+
commentReplyPerDay: num(br.commentReplyPerDay, 1, 0, 20),
|
|
90
|
+
hotComments: num(br.hotComments, 6, 0, 30),
|
|
91
91
|
danmaku: br.danmaku !== false,
|
|
92
92
|
comment: br.comment === true,
|
|
93
|
-
commentPerDay: num(br.commentPerDay, 1, 0,
|
|
93
|
+
commentPerDay: num(br.commentPerDay, 1, 0, 20),
|
|
94
94
|
frames: br.frames === true,
|
|
95
|
-
framesPerDay: num(br.framesPerDay, 1, 0,
|
|
95
|
+
framesPerDay: num(br.framesPerDay, 1, 0, 15),
|
|
96
96
|
framesPerDayChat: num(br.framesPerDayChat, 10, 0, 30),
|
|
97
97
|
search: br.search === true,
|
|
98
98
|
searchKeywords: (Array.isArray(br.searchKeywords) ? br.searchKeywords : [])
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Optional local text journal. Only explicitly selected channels; no attachment capture. */
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { existsSync,mkdirSync,chmodSync,openSync,closeSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fishSoulDir } from './db.js';
|
|
6
|
+
const DAY=86400000;
|
|
7
|
+
export class ChatArchive {
|
|
8
|
+
constructor({getConfig,log=()=>{}}){this.getConfig=getConfig;this.log=log;this.db=null;this.error='';this.failures=0;}
|
|
9
|
+
config(){const c=this.getConfig()?.chatArchive ?? {};return {enabled:c.enabled===true,channels:Array.isArray(c.channels)?c.channels.filter(x=>typeof x==='string'&&x.length>0):[],days:Math.max(1,Math.min(3650,Math.floor(Number(c.days)||30)))};}
|
|
10
|
+
open(create=false){if(this.db)return this.db;const path=join(fishSoulDir(),'chat-archive.db');if(!create&&!existsSync(path))return null;mkdirSync(fishSoulDir(),{recursive:true,mode:0o700});if(!existsSync(path)){try {closeSync(openSync(path,'wx',0o600));}catch(e){if(e.code!=='EEXIST')throw e;}}chmodSync(path,0o600);const db=new DatabaseSync(path);db.exec('PRAGMA journal_mode=WAL; PRAGMA secure_delete=ON; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS chat_text(id INTEGER PRIMARY KEY, channel_id TEXT NOT NULL, who TEXT NOT NULL, self INTEGER NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS chat_text_channel_at ON chat_text(channel_id,at DESC);');this.db=db;return db;}
|
|
11
|
+
add(channel,line){const cfg=this.config();if(!cfg.enabled||!cfg.channels.includes(channel))return;try {const text=String(line?.text??'').trim();if(!text)return;if(Buffer.byteLength(text,'utf8')>2*1024*1024)throw new Error('文本过长');this.open(true).prepare('INSERT INTO chat_text(channel_id,who,self,content,at) VALUES(?,?,?,?,?)').run(channel,String(line.who??''),line.self===true?1:0,text,Date.now());this.error='';}catch {this.failures++;this.error='聊天存档写入失败';this.log('warn','[fish-soul] 聊天存档写入失败,聊天继续');}}
|
|
12
|
+
prune(){try{const db=this.open();if(!db)return;db.prepare('DELETE FROM chat_text WHERE at < ?').run(Date.now()-this.config().days*DAY);db.exec('PRAGMA wal_checkpoint(TRUNCATE)');}catch{this.error='聊天存档清理失败';}}
|
|
13
|
+
state(){let count=0, stored=[];try{const db=this.open();count=Number(db?.prepare('SELECT COUNT(*) n FROM chat_text').get()?.n??0);stored=db?db.prepare('SELECT channel_id AS channel, COUNT(*) AS count FROM chat_text GROUP BY channel_id ORDER BY max(at) DESC LIMIT 100').all():[];}catch{this.error='聊天存档读取失败';}return {ok:true,...this.config(),count,stored,error:this.error,failures:this.failures};}
|
|
14
|
+
list(channel,before=Number.MAX_SAFE_INTEGER){try {const db=this.open();const items=db?db.prepare('SELECT id,channel_id AS channel,who,self,content,at FROM chat_text WHERE channel_id=? AND id<? ORDER BY id DESC LIMIT 50').all(channel,before):[];return {ok:true,items,next:items.length===50?items[items.length-1].id:null};}catch{return {ok:false,error:'读取聊天存档失败'};}}
|
|
15
|
+
remove(channel){try{const db=this.open();const changed=db?Number(db.prepare('DELETE FROM chat_text WHERE channel_id=?').run(channel).changes):0;db?.exec('PRAGMA wal_checkpoint(TRUNCATE)');return {ok:true,changed};}catch{return {ok:false,error:'清理聊天存档失败'};}}
|
|
16
|
+
start(){this.prune();this.timer=setInterval(()=>this.prune(),3600000);this.timer.unref?.();}
|
|
17
|
+
stop(){clearInterval(this.timer);try{this.db?.close();}catch{}this.db=null;}
|
|
18
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -94,12 +94,12 @@ window.__ModuleLoader__.load({
|
|
|
94
94
|
eye: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z",
|
|
95
95
|
close: "M7 7l10 10M17 7 7 17",
|
|
96
96
|
refresh: "M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6",
|
|
97
|
-
chevron: "m7 10 5 5 5-5",
|
|
97
|
+
chevron: "m7 10 5 5 5-5", chevronUp: "m7 14 5-5 5 5",
|
|
98
98
|
edit: "M4 20h4L18.5 9.5a2.1 2.1 0 0 0-4-4L4 16v4Z M13.5 6.5l4 4",
|
|
99
99
|
sidebar: "M4 5h16v14H4Z M9 5v14",
|
|
100
100
|
info: "M12 8h.01 M11 12h1v5h1 M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z"
|
|
101
101
|
};
|
|
102
|
-
const ICON_COMPONENTS = { close: "IconCloseOutlineRegular", refresh: "IconRefreshOutlineRegular", chevron: "IconChevronDownOutlineRegular", edit: "IconEditOutlineRegular", sidebar: "IconPanelLeftOutlineRegular", info: "IconInfoOutlineRegular" };
|
|
102
|
+
const ICON_COMPONENTS = { close: "IconCloseOutlineRegular", refresh: "IconRefreshOutlineRegular", chevron: "IconChevronDownOutlineRegular", chevronUp: "IconChevronUpOutlineRegular", edit: "IconEditOutlineRegular", sidebar: "IconPanelLeftOutlineRegular", info: "IconInfoOutlineRegular" };
|
|
103
103
|
const Icon = (name, size = 16) => primitives?.[ICON_COMPONENTS[name]] ? h(primitives[ICON_COMPONENTS[name]], { size, "aria-hidden": true })
|
|
104
104
|
: h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, h("path", { d: ICONS[name] }));
|
|
105
105
|
|
|
@@ -221,7 +221,29 @@ window.__ModuleLoader__.load({
|
|
|
221
221
|
const { className, style, ...input } = props;
|
|
222
222
|
return h("span", { className: cx("fs-native-input-wrap", className), style }, h(primitives.Input, { spellCheck: false, autoComplete: "off", ...input, className: "fs-native-input" }));
|
|
223
223
|
};
|
|
224
|
-
|
|
224
|
+
function NumberControl(props) {
|
|
225
|
+
const wrap = react.useRef(null);
|
|
226
|
+
const step = (direction) => {
|
|
227
|
+
const input = wrap.current?.querySelector('input');
|
|
228
|
+
if (!input || input.disabled || input.readOnly) return;
|
|
229
|
+
input.focus();
|
|
230
|
+
const before = input.value;
|
|
231
|
+
try { direction > 0 ? input.stepUp() : input.stepDown(); } catch { return; }
|
|
232
|
+
if (before !== input.value) props.onChange?.({ target: input, currentTarget: input });
|
|
233
|
+
};
|
|
234
|
+
const chars = String(props.value ?? "").length;
|
|
235
|
+
const numeric = props.value === "" || props.value == null ? NaN : Number(props.value);
|
|
236
|
+
return h("span", { className: cx("fs-number", chars >= 4 && "long-value"), ref: wrap,
|
|
237
|
+
style: chars >= 4 ? { width: `${Math.max(72, 40 + chars * 8)}px` } : undefined },
|
|
238
|
+
Input({ type: "number", inputMode: "numeric", ...props, className: cx("num", props.className) }),
|
|
239
|
+
h("span", { className: "fs-number-steps" }, ...[1, -1].map((direction) => h("button", {
|
|
240
|
+
type: "button", key: direction, disabled: props.disabled || props.readOnly || (Number.isFinite(numeric) &&
|
|
241
|
+
(direction > 0 ? props.max != null && numeric >= Number(props.max) : props.min != null && numeric <= Number(props.min))),
|
|
242
|
+
"aria-label": `${direction > 0 ? "增加" : "减少"}${props["aria-label"] ?? "数值"}`,
|
|
243
|
+
onMouseDown: (event) => event.preventDefault(), onClick: () => step(direction)
|
|
244
|
+
}, Icon(direction > 0 ? "chevronUp" : "chevron", 9)))));
|
|
245
|
+
}
|
|
246
|
+
const Num = (props) => h(NumberControl, props);
|
|
225
247
|
const Area = (props) => h("textarea", { rows: 2, spellCheck: false, ...props, className: "fs-input wide" });
|
|
226
248
|
|
|
227
249
|
function Secret({ value, onChange, onBlur, placeholder, label }) {
|
|
@@ -230,8 +252,39 @@ window.__ModuleLoader__.load({
|
|
|
230
252
|
Input({ type: shown ? "text" : "password", value, placeholder, onChange, onBlur, autoComplete: "off", spellCheck: false, "aria-label": label }),
|
|
231
253
|
IconButton("eye", shown ? "隐藏" : "显示", () => setShown(!shown)));
|
|
232
254
|
}
|
|
233
|
-
|
|
234
|
-
|
|
255
|
+
function SelectControl({ selectProps, options }) {
|
|
256
|
+
const [open, setOpen] = react.useState(false);
|
|
257
|
+
const anchorRef = react.useRef(null);
|
|
258
|
+
const entries = [];
|
|
259
|
+
const add = (children, disabled = false) => react.Children.forEach(children, (option) => {
|
|
260
|
+
if (!react.isValidElement(option)) return;
|
|
261
|
+
if (option.type === "optgroup") {
|
|
262
|
+
entries.push({ type: "label", id: `group-${entries.length}`, text: option.props.label });
|
|
263
|
+
add(option.props.children, disabled || option.props.disabled);
|
|
264
|
+
} else if (option.type === "option") {
|
|
265
|
+
const value = String(option.props.value ?? option.props.children ?? "");
|
|
266
|
+
entries.push({ id: `option-${entries.length}`, value, label: option.props.children, disabled: disabled || option.props.disabled });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
add(options);
|
|
270
|
+
const selected = entries.find((entry) => entry.value === String(selectProps.value ?? ""));
|
|
271
|
+
if (!primitives?.Menu) return h("div", { className: "fs-affix select" },
|
|
272
|
+
h("select", { ...selectProps, className: "fs-input" }, ...options), Icon("chevron", 12));
|
|
273
|
+
return h(primitives.Menu, {
|
|
274
|
+
open: open && !selectProps.disabled, onClose: () => setOpen(false), items: entries,
|
|
275
|
+
selectedId: selected?.id, portal: true, align: "end", minWidthFromAnchor: true, anchorRef,
|
|
276
|
+
className: "fs-select-anchor",
|
|
277
|
+
onSelect: (id) => {
|
|
278
|
+
const choice = entries.find((entry) => entry.id === id && entry.type !== "label" && !entry.disabled);
|
|
279
|
+
setOpen(false);
|
|
280
|
+
if (choice && choice.value !== String(selectProps.value ?? "")) selectProps.onChange?.({ target: { value: choice.value }, currentTarget: { value: choice.value } });
|
|
281
|
+
},
|
|
282
|
+
anchor: h("button", { ref: anchorRef, type: "button", className: "fs-select-trigger", disabled: selectProps.disabled,
|
|
283
|
+
"aria-label": selectProps["aria-label"], "aria-haspopup": "menu", "aria-expanded": open,
|
|
284
|
+
onClick: () => setOpen((v) => !v) }, h("span", null, selected?.label ?? ""), Icon("chevron", 16))
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const Select = (props, ...options) => h(SelectControl, { selectProps: props, options });
|
|
235
288
|
|
|
236
289
|
const Empty = (text) => h("div", { className: "fs-empty" }, text);
|
|
237
290
|
const Item = ({ key, lead, text, title, meta, onRemove }) => h("div", { className: "fs-item", key },
|
|
@@ -597,26 +650,26 @@ window.__ModuleLoader__.load({
|
|
|
597
650
|
br.enabled !== false ? Row({ title: "看完就收藏", desc: `收进默认收藏夹,建议 1–3 个/天;要能看图的模型(flash 可以、pro 不行)` },
|
|
598
651
|
br.favorite ? Muted(`今天收藏了 ${br.favoritedToday ?? 0}`) : null,
|
|
599
652
|
sw("想再看的就收藏", br.favorite === true, (v) => save({ browse: { favorite: v } })),
|
|
600
|
-
br.favorite ? Num({ min: 0, max:
|
|
653
|
+
br.favorite ? Num({ min: 0, max: 30, value: form.favoritePerDay, "aria-label": "一天最多收藏几个", onChange: (e) => patch({ favoritePerDay: e.target.value }), onBlur: () => save() }) : null,
|
|
601
654
|
br.favorite ? Muted("个/天") : null) : null,
|
|
602
655
|
br.enabled !== false ? Row({ title: "看一眼画面", desc: `抽开头 1 分钟拼 9 格一起看,一条十几秒;要用能看图的模型(flash 可以、pro 不行)` },
|
|
603
656
|
br.frames ? Muted(`今天看了 ${br.framedToday ?? 0}`) : null,
|
|
604
657
|
sw("看一眼画面", br.frames === true, (v) => save({ browse: { frames: v } })),
|
|
605
658
|
br.frames ? Muted("自己刷的") : null,
|
|
606
|
-
br.frames ? Num({ min: 0, max:
|
|
659
|
+
br.frames ? Num({ min: 0, max: 15, value: form.framesPerDay, "aria-label": "自己刷每天最多几条", onChange: (e) => patch({ framesPerDay: e.target.value }), onBlur: () => save() }) : null,
|
|
607
660
|
br.frames ? Muted("条/天;") : null,
|
|
608
661
|
br.frames ? Muted("你发链接的") : null,
|
|
609
662
|
br.frames ? Num({ min: 0, max: 30, value: form.framesPerDayChat, "aria-label": "你发链接时每天最多几条", onChange: (e) => patch({ framesPerDayChat: e.target.value }), onBlur: () => save() }) : null,
|
|
610
663
|
br.frames ? Muted(`条/天(今天 ${br.framedTodayChat ?? 0})`) : null) : null,
|
|
611
|
-
br.enabled !== false ? Row({ title: "回一条评论",
|
|
664
|
+
br.enabled !== false ? Row({ title: "回一条评论", desc: "在她看的视频评论区挑一条值得搭话的回一句(楼中楼);不追着聊、不抬杠" },
|
|
612
665
|
sw("回一条评论", br.commentReply === true, (v) => save({ browse: { commentReply: v } })),
|
|
613
|
-
br.commentReply ? Num({ min: 0, max:
|
|
666
|
+
br.commentReply ? Num({ min: 0, max: 20, value: form.commentReplyPerDay, "aria-label": "一天最多回几条", onChange: (e) => patch({ commentReplyPerDay: e.target.value }), onBlur: () => save() }) : null,
|
|
614
667
|
br.commentReply ? Muted("条/天") : null,
|
|
615
668
|
br.commentReply ? Muted(`(今天回了 ${br.repliedToday ?? 0})`) : null,
|
|
616
669
|
br.commentReply && br.replyLast
|
|
617
670
|
? h("a", {
|
|
618
671
|
className: "fs-unit",
|
|
619
|
-
style: {
|
|
672
|
+
style: { flexBasis: "100%", marginTop: "2px" },
|
|
620
673
|
href: `https://www.bilibili.com/video/${br.replyLast.bvid}?comment_on=1`,
|
|
621
674
|
target: "_blank",
|
|
622
675
|
rel: "noreferrer",
|
|
@@ -627,7 +680,7 @@ window.__ModuleLoader__.load({
|
|
|
627
680
|
br.enabled !== false ? Row({ title: "留一句", desc: `真看完、有感觉的视频,在评论区留一句(不超过 25 字,只写不追回复),一天最多 ${br.commentPerDay ?? 1} 条` },
|
|
628
681
|
br.comment ? Muted(`今天留了 ${br.commentedToday ?? 0}`) : null,
|
|
629
682
|
sw("留一句", br.comment === true, (v) => save({ browse: { comment: v } })),
|
|
630
|
-
br.comment ? Num({ min: 0, max:
|
|
683
|
+
br.comment ? Num({ min: 0, max: 20, value: form.commentPerDay, "aria-label": "一天最多几条", onChange: (e) => patch({ commentPerDay: e.target.value }), onBlur: () => save() }) : null,
|
|
631
684
|
br.comment ? Muted("条/天") : null) : null,
|
|
632
685
|
br.enabled !== false ? Row({ title: "你发视频给她时读多细", stack: true, desc: "你在聊天里发 B站 链接,她先看一遍再回;四个数字都可以改,字数是节选上限,不写 0 就行" },
|
|
633
686
|
Num({ min: 1, max: 5, value: form.maxLinks, "aria-label": "最多读几条", onChange: (e) => patch({ maxLinks: e.target.value }), onBlur: () => save() }), Muted("条"),
|
|
@@ -789,6 +842,7 @@ window.__ModuleLoader__.load({
|
|
|
789
842
|
const [channels, setChannels] = react.useState(null);
|
|
790
843
|
const [toast, setToast] = react.useState(null);
|
|
791
844
|
const [updateAvailable, setUpdateAvailable] = react.useState(false);
|
|
845
|
+
const [storageWarning, setStorageWarning] = react.useState(null);
|
|
792
846
|
const notify = react.useCallback((text) => setToast({ key: Date.now(), text }), []);
|
|
793
847
|
const setGuideVisibility = (visible) => {
|
|
794
848
|
try { window.localStorage.setItem(guideVisibilityKey, String(visible)); }
|
|
@@ -838,6 +892,13 @@ window.__ModuleLoader__.load({
|
|
|
838
892
|
return () => clearInterval(timer);
|
|
839
893
|
}, [tab]);
|
|
840
894
|
|
|
895
|
+
react.useEffect(() => {
|
|
896
|
+
let active = true;
|
|
897
|
+
const loadStorage = async () => { const r = await api("GET", "/storage/state"); if (active && r?.ok) setStorageWarning(r.warning ? "容量告警" : r.budget?.consecutive >= 3 ? "记忆预算接近上限" : null); };
|
|
898
|
+
loadStorage(); const timer = setInterval(loadStorage, 60000);
|
|
899
|
+
return () => { active = false; clearInterval(timer); };
|
|
900
|
+
}, []);
|
|
901
|
+
|
|
841
902
|
// 导航红点使用同一只读状态接口;版本页自身检查后的结果即时同步。
|
|
842
903
|
react.useEffect(() => {
|
|
843
904
|
if (tab === "version") return undefined;
|
|
@@ -871,7 +932,7 @@ window.__ModuleLoader__.load({
|
|
|
871
932
|
h("div", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
|
|
872
933
|
h(GroupedTabs, { items: tabs, value: tab, onChange: goTab, hasUpdate: updateAvailable }))),
|
|
873
934
|
h("main", { className: "fs-main" },
|
|
874
|
-
h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label)),
|
|
935
|
+
h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label), storageWarning ? h("span", { role: "status", style: { marginLeft: "auto" } }, Button({ variant: "ghost", onClick: () => goTab("inspector") }, storageWarning)) : null),
|
|
875
936
|
h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${tab}`, key: tab }, h(Boundary, { name: "这一页" }, pane))),
|
|
876
937
|
toast === null ? null : h("div", { className: "fs-toast", role: "status", key: toast.key }, toast.text)));
|
|
877
938
|
}
|
package/lib/client.soul.js
CHANGED
|
@@ -16,7 +16,7 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
return d.toDateString() === new Date().toDateString() ? hm : `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
|
|
17
17
|
};
|
|
18
18
|
const STAGES = [["stranger", "陌生人"], ["acquaintance", "认识"], ["friend", "朋友"], ["close", "很熟"]];
|
|
19
|
-
const KIND = { fact: "事实", event: "事件", feeling: "感受", episode: "聊过" };
|
|
19
|
+
const KIND = { fact: "事实", event: "事件", feeling: "感受", episode: "聊过", diary: "日记", weekly: "周记" };
|
|
20
20
|
const stageLabel = (v) => STAGES.find(([k]) => k === v)?.[1] ?? v ?? "";
|
|
21
21
|
const fmtDay = (ms) => (typeof ms === "number" && Number.isFinite(ms) ? `${new Date(ms).getFullYear()}-${String(new Date(ms).getMonth() + 1).padStart(2, "0")}-${String(new Date(ms).getDate()).padStart(2, "0")}` : "--");
|
|
22
22
|
|
|
@@ -36,6 +36,7 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
function MemoryItem({ m, ui, api, notify, refresh, curGen, selectable, selected, onSelect }) {
|
|
37
37
|
const { h, Tag, Dot, IconButton, Area, Num, Select, Button } = ui;
|
|
38
38
|
const [edit, setEdit] = react.useState(null);
|
|
39
|
+
const [sources, setSources] = react.useState(null);
|
|
39
40
|
const imp = m.importance ?? 1;
|
|
40
41
|
const vec = m.vecGenerationId == null ? ["idle", "待向量化"] : m.vecGenerationId === curGen ? ["done", "已向量化"] : ["warning", "向量过期"];
|
|
41
42
|
const origin = `出处:${m.source ?? ""}${m.evidence ? ` · ${m.evidence}` : ""}`;
|
|
@@ -43,8 +44,13 @@ window.__ModuleLoader__.load({
|
|
|
43
44
|
const r = await api("POST", "/inspector/forget", { targetType: "memory", id: m.id });
|
|
44
45
|
if (r?.ok) { notify(`已删除 #${m.id}(能在下面"改掉 / 过时的"里恢复)`); refresh(); } else notify(r?.error ?? "删除失败");
|
|
45
46
|
}
|
|
47
|
+
async function toggleImportanceLock() {
|
|
48
|
+
const r = await api("POST", "/inspector/importance-lock", { id: m.id, locked: !m.importanceLocked });
|
|
49
|
+
if (r?.ok) { notify(m.importanceLocked ? "已允许自动提升" : "重要度已锁定"); refresh(); } else notify(r?.error ?? "设置失败");
|
|
50
|
+
}
|
|
46
51
|
async function save() {
|
|
47
|
-
const
|
|
52
|
+
const importanceChanged = Number(edit.importance) !== (m.importance ?? 1);
|
|
53
|
+
const r = await api("POST", "/inspector/memory-edit", { id: m.id, content: edit.content, kind: edit.kind, ...(importanceChanged ? { importance: Number(edit.importance) } : {}) });
|
|
48
54
|
if (r?.ok) { setEdit(null); notify(r.replaced ? "已改(旧的留档了)" : "已保存"); refresh(); } else notify(r?.error ?? "保存失败");
|
|
49
55
|
}
|
|
50
56
|
if (edit !== null) {
|
|
@@ -52,7 +58,7 @@ window.__ModuleLoader__.load({
|
|
|
52
58
|
Area({ value: edit.content, rows: 2, "aria-label": "记忆内容", onChange: (e) => setEdit({ ...edit, content: e.target.value }) }),
|
|
53
59
|
h("div", { className: "fs-ctl" },
|
|
54
60
|
Select({ value: edit.kind, "aria-label": "类型", onChange: (e) => setEdit({ ...edit, kind: e.target.value }) },
|
|
55
|
-
...Object.entries(KIND).map(([k, v]) => h("option", { key: k, value: k }, v))),
|
|
61
|
+
...Object.entries(KIND).filter(([k]) => k !== "weekly" && k !== "diary").map(([k, v]) => h("option", { key: k, value: k }, v))),
|
|
56
62
|
h("span", { className: "fs-unit" }, "重要度"),
|
|
57
63
|
Num({ min: 1, max: 5, value: edit.importance, "aria-label": "重要度", onChange: (e) => setEdit({ ...edit, importance: e.target.value }) }),
|
|
58
64
|
h("span", { className: "fs-fill-gap" }),
|
|
@@ -60,23 +66,27 @@ window.__ModuleLoader__.load({
|
|
|
60
66
|
Button({ variant: "primary", onClick: save, disabled: String(edit.content).trim() === "" }, "保存")),
|
|
61
67
|
h("span", { className: "fs-item-sub" }, origin));
|
|
62
68
|
}
|
|
63
|
-
return h("div", { className: "fs-item" },
|
|
69
|
+
return h(react.Fragment, null, h("div", { className: "fs-item" },
|
|
64
70
|
selectable ? h(ui.Check, { checked: selected === true, label: `选中 #${m.id}`, hideLabel: true, onChange: (v) => onSelect(m.id, v) }) : null,
|
|
65
71
|
Tag(KIND[m.kind] ?? m.kind),
|
|
66
72
|
h("span", { className: "fs-item-main", title: `${m.content}\n${origin}` }, m.content),
|
|
67
73
|
h("span", { className: "fs-item-meta" },
|
|
74
|
+
m.importanceAutoRecalled ? Tag("召回提升", "outline") : null,
|
|
75
|
+
Button({ variant: "ghost", onClick: toggleImportanceLock, "aria-label": m.importanceLocked ? `解锁 #${m.id} 的重要度` : `锁定 #${m.id} 的重要度` }, m.importanceLocked ? "已锁定" : "锁定"),
|
|
68
76
|
m.visibility === "private" ? Tag("私密", "warning", { title: "私聊里说的,群里不会提" }) : null,
|
|
69
77
|
m.about ? Tag(`关于 ${m.aboutName ?? m.about.replace(/^user:/, "")}`, "outline") : null,
|
|
70
78
|
imp >= 2 ? h("span", { title: imp >= 3 ? "不会淡忘" : undefined }, "★".repeat(imp)) : null,
|
|
71
79
|
h("span", { title: origin }, m.source ?? ""),
|
|
72
80
|
h("span", { title: vec[1], style: { display: "inline-flex" } }, Dot(vec[0])),
|
|
73
|
-
IconButton("edit", "修改", () => setEdit({ content: m.content, importance: imp, kind: m.kind })),
|
|
74
|
-
IconButton("close", "删除", forget)))
|
|
81
|
+
m.kind === "weekly" ? Button({ variant: "ghost", onClick: async () => { if (sources) { setSources(null); return; } const r = await api("GET", `/memory/weekly/sources?summaryId=${m.id}`); if (r?.ok) setSources(r.sources); else notify(r?.error ?? "来源读取失败"); } }, sources ? "收起来源" : "查看来源") : IconButton("edit", "修改", () => setEdit({ content: m.content, importance: imp, kind: m.kind })),
|
|
82
|
+
IconButton("close", "删除", forget))),
|
|
83
|
+
sources ? h("div", { className: "fs-item-sub" }, ...sources.map((x) => h("div", { key: x.id }, `#${x.id} · ${x.content}`))) : null);
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
// ── 人物:关系 / 称呼 / 印象 ──
|
|
78
87
|
function PersonCard({ ownerId, ui, api, notify, onChanged }) {
|
|
79
|
-
const { h, Row, Group, Input, Select, Area, Button, Tag, Muted, Empty, Item } = ui;
|
|
88
|
+
const { h, Row, Group, Input, Select, Area, Button, Tag, Muted, Empty, Item, useConfirm } = ui;
|
|
89
|
+
const [confirm, confirmNode] = useConfirm();
|
|
80
90
|
const [data, load] = usePoll(api, `/inspector/person?ownerId=${encodeURIComponent(ownerId)}`, 0);
|
|
81
91
|
const [form, setForm] = react.useState(null);
|
|
82
92
|
const [busy, setBusy] = react.useState(false);
|
|
@@ -104,6 +114,14 @@ window.__ModuleLoader__.load({
|
|
|
104
114
|
const r = await api("POST", "/inspector/memory-restore", { id });
|
|
105
115
|
if (r?.ok) { notify(`已恢复 #${id}`); await load(); onChanged(); } else notify(r?.error ?? "恢复失败");
|
|
106
116
|
}
|
|
117
|
+
async function forgetAll() {
|
|
118
|
+
const dry = await api("POST", "/inspector/bulk", { filter: { ownerId }, action: "forget", dryRun: true });
|
|
119
|
+
if (!dry?.ok) { notify(dry?.error ?? "操作失败"); return; }
|
|
120
|
+
if (dry.matched === 0) { notify("没有要删的记忆"); return; }
|
|
121
|
+
if (!(await confirm({ title: `清空 ${p.displayName} 的记忆`, description: `删掉这个人的全部 ${dry.matched} 条记忆?(能在下面「改掉 / 过时的」里恢复)`, danger: true }))) return;
|
|
122
|
+
const r = await api("POST", "/inspector/bulk", { filter: { ownerId }, action: "forget" });
|
|
123
|
+
if (r?.ok) { notify(`已删除 ${r.changed} 条记忆`); await load(); onChanged(); } else notify(r?.error ?? "删除失败");
|
|
124
|
+
}
|
|
107
125
|
const ids = [p.qqUin && `QQ ${p.qqUin}`, p.biliUid && `B站 ${p.biliUid}`, p.douyinName && `抖音 ${p.douyinName}`].filter(Boolean).join(" · ");
|
|
108
126
|
const dirty = form !== null && form.profile !== (prof?.text ?? "");
|
|
109
127
|
const fieldsSaved = data.profileFields ?? {};
|
|
@@ -135,25 +153,28 @@ window.__ModuleLoader__.load({
|
|
|
135
153
|
h("span", { className: "fs-fill-gap" }),
|
|
136
154
|
prof?.locked ? Button({ variant: "ghost", disabled: busy, onClick: () => save({ profileLocked: false }, "交还给 Dream 了") }, "交给 Dream") : null,
|
|
137
155
|
Button({ variant: "ghost", disabled: busy, onClick: dream }, "现在整理一次"),
|
|
138
|
-
Button({ variant: "primary", disabled: busy || !dirty, onClick: () => save({ profile: form.profile }, "印象已保存") }, "保存")) : null
|
|
156
|
+
Button({ variant: "primary", disabled: busy || !dirty, onClick: () => save({ profile: form.profile }, "印象已保存") }, "保存")) : null,
|
|
157
|
+
p.canonicalId !== "master" && p.canonicalId !== "self" ? Row({ title: "清空记忆", desc: "删掉这个人的全部记忆(人还在,之后还能聊)" },
|
|
158
|
+
Button({ variant: "ghost", disabled: busy, onClick: forgetAll }, "全部删掉")) : null),
|
|
139
159
|
(data.episodes ?? []).length === 0 ? null : Group({ title: "最近聊过" },
|
|
140
160
|
h("div", { className: "fs-list" }, data.episodes.map((x) => Item({ key: x.id, text: x.content, meta: [h("span", { key: "t" }, fmtDay(x.at))] })))),
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
(data.outdated ?? []).length === 0 ? null : Group({ title: "改掉 / 过时的", aside: Tag(String(data.outdated.length), "outline") },
|
|
162
|
+
h("div", { className: "fs-list" }, data.outdated.map((m) => Item({
|
|
163
|
+
key: m.id, lead: Tag(m.status === "outdated" ? "过时" : m.status === "superseded" ? "改掉" : "删了", "outline"),
|
|
164
|
+
text: m.content, title: `${m.content}\n${m.evidence || m.source}`,
|
|
165
|
+
meta: [Button({ key: "r", variant: "ghost", onClick: () => restore(m.id) }, "恢复")]
|
|
166
|
+
})))));
|
|
167
|
+
}
|
|
148
168
|
|
|
149
169
|
// ── 导入聊天记录 / 备份 ──
|
|
150
170
|
function ImportBackup({ ui, api, notify, relTime, onDone }) {
|
|
151
|
-
const { h, Row, Group, Input, Num, Button, Switch, State, Muted, Tag } = ui;
|
|
171
|
+
const { h, Row, Group, Input, Num, Button, Switch, State, Muted, Tag, useConfirm } = ui;
|
|
152
172
|
const [pv, setPv] = react.useState(null);
|
|
153
173
|
const [opts, setOpts] = react.useState(null);
|
|
154
174
|
const [dbPath, setDbPath] = react.useState("");
|
|
155
175
|
const [showDb, setShowDb] = react.useState(false);
|
|
156
176
|
const [busy, setBusy] = react.useState(false);
|
|
177
|
+
const [confirm, confirmNode] = useConfirm();
|
|
157
178
|
const [job, loadJob] = usePoll(api, "/import/history/state", 2000);
|
|
158
179
|
const [bk, loadBk] = usePoll(api, "/backup/state", 0);
|
|
159
180
|
const fileRef = react.useRef(null);
|
|
@@ -208,7 +229,7 @@ window.__ModuleLoader__.load({
|
|
|
208
229
|
if (r?.ok) { notify(`已备份:${r.file}`); loadBk(); } else notify(r?.error ?? "备份失败");
|
|
209
230
|
}
|
|
210
231
|
async function restore(f) {
|
|
211
|
-
if (!
|
|
232
|
+
if (!(await confirm({ title: "恢复备份", description: `恢复到 ${f.file}?\n重启 DSH 后生效:现在的库会先另存成 pre-restore-….db,恢复错了还能换回来。\n这份备份之后新记的东西会回到这份备份的样子。`, danger: true }))) return;
|
|
212
233
|
const r = await api("POST", "/backup/restore", { file: f.file });
|
|
213
234
|
if (r?.ok) { notify(`已选好(${r.pending.memories} 条记忆),重启 DSH 后换上`); loadBk(); } else notify(r?.error ?? "这份备份用不了");
|
|
214
235
|
}
|
|
@@ -302,7 +323,8 @@ window.__ModuleLoader__.load({
|
|
|
302
323
|
h("span", { className: "fs-item-main" }, f.file),
|
|
303
324
|
h("span", { className: "fs-item-meta" },
|
|
304
325
|
Muted(`${Math.max(1, Math.round(f.size / 1024))} KB · ${relTime(f.at, Date.now())}`),
|
|
305
|
-
bk?.pendingRestore?.file === f.file ? Tag("等重启", "warning") : Button({ variant: "ghost", disabled: busy, onClick: () => restore(f) }, "恢复到这份"))))) : null)
|
|
326
|
+
bk?.pendingRestore?.file === f.file ? Tag("等重启", "warning") : Button({ variant: "ghost", disabled: busy, onClick: () => restore(f) }, "恢复到这份"))))) : null),
|
|
327
|
+
confirmNode);
|
|
306
328
|
}
|
|
307
329
|
|
|
308
330
|
// ── 记忆调试器:输一句话,看她会想起哪几条、为什么没想起别的 ──
|
|
@@ -322,7 +344,7 @@ window.__ModuleLoader__.load({
|
|
|
322
344
|
Tag(lead, tone),
|
|
323
345
|
h("span", { className: "fs-item-main", title: m.content }, m.content),
|
|
324
346
|
h("span", { className: "fs-item-meta" },
|
|
325
|
-
m.score != null ? h("span", { className: "num", title: `原始 ${m.raw} × 新鲜度 ${m.decay}${m.bm25Rank ? ` · 关键词第 ${m.bm25Rank}` : ""}${m.vecRank ? ` · 意思第 ${m.vecRank}` : ""}` }, m.score.toFixed(3)) : null,
|
|
347
|
+
m.score != null ? h("span", { className: "num", title: `原始 ${m.raw} × 新鲜度 ${m.decay} × ${({ recent: "近期", longTerm: "长期", weekly: "周记" })[m.tier] ?? "记忆"} ${m.tierWeight ?? 1}${m.bm25Rank ? ` · 关键词第 ${m.bm25Rank}` : ""}${m.vecRank ? ` · 意思第 ${m.vecRank}` : ""}` }, m.score.toFixed(3)) : null,
|
|
326
348
|
m.when ? h("span", null, m.when) : null,
|
|
327
349
|
Muted(m.why)));
|
|
328
350
|
return Group({ title: "记忆调试", aside: res ? Tag(res.vector ? "关键词 + 意思" : "只有关键词", "outline", { title: res.vector ? "向量检索参与了" : "向量没开或还没算好,只按关键词" }) : null },
|
|
@@ -338,15 +360,99 @@ window.__ModuleLoader__.load({
|
|
|
338
360
|
}
|
|
339
361
|
|
|
340
362
|
// ── 记忆检视 ──
|
|
363
|
+
function WeeklyPanel({ ui, api, notify, onDone }) {
|
|
364
|
+
const { h, Group, Row, Button, Switch, Muted, useConfirm } = ui;
|
|
365
|
+
const [data, load] = usePoll(api, "/memory/weekly/state", 30000);
|
|
366
|
+
const [busy, setBusy] = react.useState(false);
|
|
367
|
+
const [confirm, confirmNode] = useConfirm();
|
|
368
|
+
if (!data) return null;
|
|
369
|
+
const consent = () => confirm({ title: "生成周记", description: "会把相关记忆(可能包含私密内容)发送给“写日记”使用的模型,模型可能由远程服务提供。原记忆不会删除。继续吗?" });
|
|
370
|
+
async function setAuto(v) {
|
|
371
|
+
if (v && !(await consent())) return;
|
|
372
|
+
setBusy(true); const r = await api("POST", "/memory/weekly/config", { enabled: v }); setBusy(false);
|
|
373
|
+
if (r?.ok) { await load(); notify(v ? "已开启自动周记" : "已关闭自动周记"); } else notify(r?.error ?? "设置失败");
|
|
374
|
+
}
|
|
375
|
+
async function run() {
|
|
376
|
+
if (!(await consent())) return;
|
|
377
|
+
setBusy(true); const r = await api("POST", "/memory/weekly/run", {}); setBusy(false);
|
|
378
|
+
if (r?.ok) { notify(r.empty ? "暂无可整理的记忆" : `已生成周记(${r.count} 条来源)`); await load(); onDone(); } else notify(r?.error ?? "生成失败");
|
|
379
|
+
}
|
|
380
|
+
return h(react.Fragment, null,
|
|
381
|
+
Group({ title: "周记" },
|
|
382
|
+
Row({ title: "自动生成", meta: "按周整理,保留原记忆;使用写日记模型" }, h(Switch, { label: "自动生成周记", checked: data.enabled, disabled: busy, onChange: setAuto })),
|
|
383
|
+
Row({ title: "待整理" }, Muted(`${data.pending} 条`), Button({ disabled: busy || data.running || !data.pending, onClick: run }, busy || data.running ? "生成中" : "生成一批"))),
|
|
384
|
+
confirmNode);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function StoragePanel({ ui, api, notify }) {
|
|
388
|
+
const { h, Group, Row, Input, Num, Button, Switch, Muted, useConfirm } = ui;
|
|
389
|
+
const [storage, load] = usePoll(api, "/storage/state", 30000);
|
|
390
|
+
const [chat, loadChat] = usePoll(api, "/chat-archive/state", 30000);
|
|
391
|
+
const [form, setForm] = react.useState(null);
|
|
392
|
+
const [threshold, setThreshold] = react.useState(null);
|
|
393
|
+
const [busy, setBusy] = react.useState(false);
|
|
394
|
+
const [items, setItems] = react.useState(null);
|
|
395
|
+
const [history, setHistory] = react.useState(null);
|
|
396
|
+
const [historyQuery, setHistoryQuery] = react.useState("");
|
|
397
|
+
const [showAllFiles, setShowAllFiles] = react.useState(false);
|
|
398
|
+
const [confirm, confirmNode] = useConfirm();
|
|
399
|
+
react.useEffect(() => { if (chat && form === null) setForm({ enabled: chat.enabled, channels: chat.channels.join(", "), days: chat.days }); }, [chat, form]);
|
|
400
|
+
react.useEffect(() => { if (storage && threshold === null) setThreshold({ warnMB: storage.warnMB, freeMB: storage.freeMB, keep: storage.keep ?? 0 }); }, [storage, threshold]);
|
|
401
|
+
if (!storage || !chat || !form || !threshold) return null;
|
|
402
|
+
const size = (x) => `${(Number(x || 0) / 1048576).toFixed(1)} MB`;
|
|
403
|
+
async function storageConfig(patch) { const r=await api("POST", "/storage/config", patch); if(r?.ok){await load();notify("已保存");}else notify(r?.error??"保存失败"); }
|
|
404
|
+
async function snapshot() { setBusy(true);const r=await api("POST", "/storage/snapshot", {});setBusy(false);if(r?.ok){notify("存档已校验");await load();}else notify(r?.error??"存档失败"); }
|
|
405
|
+
async function fileAction(action, file) {
|
|
406
|
+
const warning = action === "restore" ? "恢复将于重启后替换当前记忆库;当前库会先另存。确定?" : "永久删除这份存档?不会删除在线记忆。";
|
|
407
|
+
if(!(await confirm({ title: action === "restore" ? "恢复存档" : "删除存档", description: warning, danger: action === "delete" })))return;setBusy(true);const r=await api("POST", `/storage/${action}`, {file});setBusy(false);
|
|
408
|
+
if(r?.ok){notify(action === "restore" ? "已安排恢复,重启后生效" : "存档已删除");await load();}else notify(r?.error??"操作失败");
|
|
409
|
+
}
|
|
410
|
+
async function saveChat() {
|
|
411
|
+
const channels=form.channels.split(/[,,\n]/).map(x=>x.trim()).filter(Boolean);
|
|
412
|
+
if(Number(form.days)<chat.days && !(await confirm({ title: "缩短保留期限", description: "会立即清理已过期的本地聊天文本,备份和复制件不会同步清理。继续?", danger: true })))return;
|
|
413
|
+
if(form.enabled && !(await confirm({ title: "启用聊天文本存档", description: "仅保存所列会话中插件接收到的文本,可能包含私密内容;文件未加密。附件和未接入的消息不会保存,历史消息无法补录。继续?" })))return;
|
|
414
|
+
const r=await api("POST", "/chat-archive/config", {enabled:form.enabled,channels,days:Number(form.days)});
|
|
415
|
+
if(r?.ok){setForm({enabled:r.enabled,channels:r.channels.join(", "),days:r.days});setItems(null);await loadChat();notify("聊天存档设置已保存");}else notify(r?.error??"保存失败");
|
|
416
|
+
}
|
|
417
|
+
async function showChat(channel, before) {const r=await api("GET", `/chat-archive/list?channel=${encodeURIComponent(channel)}${before ? `&before=${before}` : ""}`);if(r?.ok)setItems(prev=>({channel, next:r.next, rows:before && prev?.channel === channel ? [...prev.rows,...r.items] : r.items}));else notify(r?.error??"读取失败");}
|
|
418
|
+
async function dropChat(channel) {if(!(await confirm({ title: "删除聊天文本", description: `永久删除 ${channel} 的本地聊天文本?备份和复制件需另行清理。`, danger: true })))return;const r=await api("POST", "/chat-archive/delete", {channel});if(r?.ok){setItems(null);await loadChat();notify("聊天文本已删除");}else notify(r?.error??"删除失败");}
|
|
419
|
+
async function searchHistory(before) {const r=await api("GET", `/storage/search?q=${encodeURIComponent(historyQuery)}${before ? `&before=${before}` : ""}`);if(r?.ok)setHistory(prev=>({next:r.next,rows:before&&prev?[...prev.rows,...r.items]:r.items}));else notify(r?.error??"搜索失败");}
|
|
420
|
+
return h(react.Fragment,null,
|
|
421
|
+
Group({title:"记忆存档",tip:"压缩快照保留全部记忆及来源,不删除在线数据;本地文件未加密"},
|
|
422
|
+
Row({title:"容量",meta:`数据库 ${size(storage.databaseBytes)} · 存档 ${size(storage.archivesBytes)} · 备份 ${size(storage.backupsBytes)} · 聊天 ${size(storage.chatBytes)}`}, Muted(storage.warning ? "容量告警" : `可用 ${storage.freeBytes == null ? "未知" : size(storage.freeBytes)}`)),
|
|
423
|
+
Row({title:"记忆分层"},Muted(`近期 ${storage.tiers.recent} · 长期 ${storage.tiers.longTerm} · 周记 ${storage.tiers.weekly}`)),
|
|
424
|
+
Row({title:"上下文预算",meta:"按字符估算,仅监测记忆片段"},Muted(storage.budget.consecutive >= 3 ? `连续 ${storage.budget.consecutive} 次接近上限` : `最近 ${Math.round(storage.budget.lastRatio*100)}%`)),
|
|
425
|
+
Row({title:"每周自动存档",meta:"压缩校验,不删除原文;存档未加密"},h(Switch,{label:"每周自动存档",checked:storage.auto,disabled:busy,onChange:v=>storageConfig({auto:v})})),
|
|
426
|
+
Row({title:"存档预览",meta:`现有 ${storage.files.length} 份,当前库 ${size(storage.databaseBytes)};每次大小取决于数据`},Button({disabled:busy||storage.running,onClick:snapshot},busy||storage.running?"处理中":"立即存档")),
|
|
427
|
+
storage.error?Row({title:"存档异常"},Muted(storage.error)):null,
|
|
428
|
+
Row({title:"占用告警 MB"},Num({min:50,max:1048576,value:threshold.warnMB,"aria-label":"占用告警 MB",onChange:e=>setThreshold({...threshold,warnMB:e.target.value})})),
|
|
429
|
+
Row({title:"剩余空间告警 MB"},Num({min:50,max:1048576,value:threshold.freeMB,"aria-label":"剩余空间告警 MB",onChange:e=>setThreshold({...threshold,freeMB:e.target.value})})),
|
|
430
|
+
Row({title:"存档最多保留",meta:"超过就自动删最旧的;0 = 不限。在线记忆永远不删"},Num({min:0,max:200,value:threshold.keep,"aria-label":"存档最多保留几份",onChange:e=>setThreshold({...threshold,keep:e.target.value})}),Button({onClick:()=>storageConfig({warnMB:Number(threshold.warnMB),freeMB:Number(threshold.freeMB),keep:Number(threshold.keep)})},"保存")),
|
|
431
|
+
...storage.files.slice(0,showAllFiles ? undefined : 5).map(f=>Row({title:`${f.file.slice(7,22)} · ${f.file.slice(23,31)}`,meta:size(f.size)},Button({disabled:busy,onClick:()=>fileAction("restore",f.file)},"恢复"),Button({disabled:busy,onClick:()=>fileAction("delete",f.file)},"删除"))),
|
|
432
|
+
storage.files.length>5 ? Button({variant:"ghost",onClick:()=>setShowAllFiles(!showAllFiles)},showAllFiles ? "收起存档" : `显示全部 ${storage.files.length} 份`) : null,
|
|
433
|
+
Row({title:"全部记忆记录",meta:"包括已归档和失效记录",stack:true},Input({className:"wide",value:historyQuery,placeholder:"搜索记忆", "aria-label":"搜索全部记忆",onChange:e=>setHistoryQuery(e.target.value)}),Button({onClick:()=>searchHistory(null)},"搜索")),
|
|
434
|
+
history?h("div",{className:"fs-list"},...history.rows.map(x=>h("div",{className:"fs-row",key:x.id},h("span",{style:{overflowWrap:"anywhere"}},`#${x.id} · ${{active:"当前",archived:"已归档",outdated:"过时",superseded:"已替换"}[x.status] ?? x.status} · ${x.visibility === "private" ? "私密 · " : ""}${x.content}`))),history.next?Button({onClick:()=>searchHistory(history.next)},"加载更早"):null):null),
|
|
435
|
+
Group({title:"聊天文本存档",tip:"默认关闭;仅保存选定会话中插件接收到的文本,不包括附件或全部平台消息"},
|
|
436
|
+
Row({title:"启用",meta:"本地未加密,仅从开启后记录;历史不补录"},h(Switch,{label:"聊天文本存档",checked:form.enabled,onChange:v=>setForm({...form,enabled:v})})),
|
|
437
|
+
Row({title:"会话 ID",meta:"多个 ID 用逗号分隔,例如 private:123、group:456",stack:true},Input({className:"wide",value:form.channels,"aria-label":"聊天存档会话 ID",onChange:e=>setForm({...form,channels:e.target.value})})),
|
|
438
|
+
Row({title:"保留天数",meta:"过期文本按天清理;备份和复制件需另行清理"},Num({min:1,max:3650,value:form.days,"aria-label":"聊天存档保留天数",onChange:e=>setForm({...form,days:e.target.value})}),Button({onClick:saveChat},"保存")),
|
|
439
|
+
Row({title:"已保存文本"},Muted(`${chat.count} 条`)),
|
|
440
|
+
...(chat.stored??[]).map(x=>Row({title:x.channel},Muted(`${x.count} 条`),Button({onClick:()=>showChat(x.channel,null)},"查看"),Button({onClick:()=>dropChat(x.channel)},"删除"))),
|
|
441
|
+
chat.error?Row({title:"存档异常"},Muted(chat.error)):null,
|
|
442
|
+
items?h("div",{className:"fs-list"},...items.rows.map(x=>h("div",{className:"fs-row",key:x.id},h("span",{style:{overflowWrap:"anywhere",whiteSpace:"pre-wrap"}},`${x.self?"我":x.who}:${x.content}`))),items.next?Button({onClick:()=>showChat(items.channel,items.next)},"加载更早"):null):null),
|
|
443
|
+
confirmNode);
|
|
444
|
+
}
|
|
445
|
+
|
|
341
446
|
function InspectorPane(props) {
|
|
342
447
|
const { api, notify, ui } = props;
|
|
343
|
-
const { h, Tag, Empty, Item, Muted, Group, Input, Button } = ui;
|
|
448
|
+
const { h, Tag, Empty, Item, Muted, Group, Input, Button, useConfirm } = ui;
|
|
344
449
|
const [owner, setOwner] = react.useState("all");
|
|
345
450
|
const [q, setQ] = react.useState("");
|
|
346
451
|
const [qApplied, setQApplied] = react.useState("");
|
|
347
452
|
const [source, setSource] = react.useState("");
|
|
348
453
|
const [bulk, setBulk] = react.useState(false);
|
|
349
454
|
const [sel, setSel] = react.useState(() => new Set());
|
|
455
|
+
const [confirm, confirmNode] = useConfirm();
|
|
350
456
|
const qs = new URLSearchParams({ ...(owner !== "all" ? { ownerId: owner } : {}), ...(qApplied ? { q: qApplied } : {}), ...(source ? { source } : {}), ...(bulk ? { limit: "500" } : {}) }).toString();
|
|
351
457
|
const [data, load] = usePoll(api, `/inspector/overview${qs ? `?${qs}` : ""}`, 0);
|
|
352
458
|
async function dropPending(id) {
|
|
@@ -369,8 +475,8 @@ window.__ModuleLoader__.load({
|
|
|
369
475
|
const dry = await api("POST", "/inspector/bulk", { ...target, action, value, dryRun: true });
|
|
370
476
|
if (!dry?.ok) { notify(dry?.error ?? "操作失败"); return; }
|
|
371
477
|
if (dry.matched === 0) { notify("没有符合条件的"); return; }
|
|
372
|
-
if (!
|
|
373
|
-
} else if (action === "forget" && !
|
|
478
|
+
if (!(await confirm({ title: label, description: `符合当前筛选的 ${dry.matched} 条,确定?`, danger: action === "forget" }))) return;
|
|
479
|
+
} else if (action === "forget" && !(await confirm({ title: "删除记忆", description: `删掉选中的 ${sel.size} 条?(能在人物卡里恢复)`, danger: true }))) return;
|
|
374
480
|
const r = await api("POST", "/inspector/bulk", { ...target, action, value });
|
|
375
481
|
if (r?.ok) { notify(`${label}:${r.changed} 条`); setSel(new Set()); load(); } else notify(r?.error ?? "操作失败");
|
|
376
482
|
}
|
|
@@ -413,7 +519,10 @@ window.__ModuleLoader__.load({
|
|
|
413
519
|
: memories.map((m) => h(MemoryItem, { key: m.id, m, ui, api, notify, refresh: load, curGen: data.currentGenerationId ?? 1, selectable: bulk, selected: sel.has(m.id), onSelect })))),
|
|
414
520
|
pendings.length === 0 ? null : Group({ title: "待办", aside: Tag(String(pendings.length), "outline") },
|
|
415
521
|
h("div", { className: "fs-list" }, pendings.map((p) => Item({ key: p.id, lead: Muted(props.fmtDueAt(p.dueAt), { className: "fs-unit num" }), text: p.reason, meta: [p.channelId ?? ""], onRemove: () => dropPending(p.id) })))),
|
|
416
|
-
h(
|
|
522
|
+
h(WeeklyPanel, { ui, api, notify, onDone: load }),
|
|
523
|
+
h(StoragePanel, { ui, api, notify }),
|
|
524
|
+
h(ImportBackup, { ui, api, notify, relTime: props.relTime, onDone: load }),
|
|
525
|
+
confirmNode);
|
|
417
526
|
}
|
|
418
527
|
|
|
419
528
|
// ── 动态:为什么回 / 为什么没回 / 想起了什么 ──
|
|
@@ -477,14 +586,16 @@ window.__ModuleLoader__.load({
|
|
|
477
586
|
if (list.length === 0) return;
|
|
478
587
|
setBusy(true);
|
|
479
588
|
let doneCount = 0; let skipped = 0; let failed = 0; let full = false;
|
|
589
|
+
const errors = [];
|
|
480
590
|
for (const f of list) {
|
|
481
591
|
if (f.size > 4 * 1024 * 1024) { skipped++; continue; }
|
|
482
592
|
const b64 = await readBase64(f);
|
|
483
|
-
if (b64 === null) { failed++; continue; }
|
|
593
|
+
if (b64 === null) { failed++; errors.push(`${f.name}:读不出来`); continue; }
|
|
484
594
|
const r = await api("POST", "/stickers/import", { name: f.name.replace(/\.[a-z]+$/i, ""), mediaType: f.type, base64: b64 });
|
|
485
595
|
if (r?.ok) doneCount++;
|
|
486
|
-
else if (r?.full === true) { failed++; full = true; break; }
|
|
487
|
-
else
|
|
596
|
+
else if (r?.full === true) { failed++; full = true; errors.push(`${f.name}:${r?.error ?? "_stickers 满了"}`); break; }
|
|
597
|
+
else if (r?.dup === true) { errors.push(`${f.name}:${r?.error ?? "已经有了"}`); }
|
|
598
|
+
else { failed++; errors.push(`${f.name}:${r?.error ?? "导入失败"}`); }
|
|
488
599
|
}
|
|
489
600
|
setBusy(false);
|
|
490
601
|
const parts = [];
|
|
@@ -493,11 +604,12 @@ window.__ModuleLoader__.load({
|
|
|
493
604
|
if (failed > 0) parts.push(`${failed} 张没导进去`);
|
|
494
605
|
if (full) parts.push("_stickers 满了");
|
|
495
606
|
notify(parts.length > 0 ? parts.join(",") : "没有导入");
|
|
607
|
+
if (errors.length > 0) notify(errors.join(";"));
|
|
496
608
|
load();
|
|
497
609
|
}
|
|
498
610
|
return Group({ title: "表情包", aside: items.length ? Tag(String(items.length), "outline") : null },
|
|
499
611
|
Row({ title: "导入本地图片" },
|
|
500
|
-
h("input", { type: "file", accept: "png,
|
|
612
|
+
h("input", { type: "file", accept: "image/png,image/jpeg,image/gif,image/webp", multiple: true, ref: picker, style: { display: "none" }, "aria-label": "选要导入的表情图", onChange: (e) => { const files = e.target.files; e.target.value = ""; importFiles(files); } }),
|
|
501
613
|
Button({ variant: "ghost", disabled: busy, "aria-label": "导入本地图片", onClick: () => picker.current?.click() }, busy ? "导入中…" : "选图导入"),
|
|
502
614
|
Muted("不占每天收藏的额度")),
|
|
503
615
|
items.length === 0 ? Empty("还没有表情包。聊天里看到喜欢的,她会自己存下来;你也可以直接把图片放进工作区的 _stickers 目录") : h("div", { className: "fs-stickers" }, items.slice(0, 60).map((x) => h(StickerCard, { key: `${x.workspacePath}/${x.file}`, x, api, apiBase, notify, refresh: load, relTime, ui, onRemove: remove }))));
|
package/lib/config-schema.js
CHANGED
|
@@ -88,8 +88,11 @@ export function fishSoulConfigSchema(z) {
|
|
|
88
88
|
sessionAllow: z.string().default('memory')
|
|
89
89
|
}).default({}),
|
|
90
90
|
onboarding: z.object({ dismissed: z.boolean().default(false) }).default({}),
|
|
91
|
+
storage: z.object({ auto: z.boolean().default(true), warnMB: z.number().default(1024), freeMB: z.number().default(512) }).default({}),
|
|
92
|
+
chatArchive: z.object({ enabled: z.boolean().default(false), channels: z.array(z.string()).default([]), days: z.number().default(30) }).default({}),
|
|
91
93
|
memory: z.object({ // v0.8 聊完自动记:一段聊天空下来 idleMinutes 分钟(或攒够 maxLines 句)就整理一次
|
|
92
94
|
autoExtract: z.boolean().default(true),
|
|
95
|
+
weeklyAuto: z.boolean().default(false),
|
|
93
96
|
idleMinutes: z.number().default(10),
|
|
94
97
|
maxLines: z.number().default(40)
|
|
95
98
|
}).default({}),
|
|
@@ -114,6 +117,7 @@ export function fishSoulConfigSchema(z) {
|
|
|
114
117
|
}).default({}),
|
|
115
118
|
retrieval: z.object({
|
|
116
119
|
fusionMethod: z.string().default('rrf'),
|
|
120
|
+
tierWeights: z.object({ recent: z.number().default(1.1), longTerm: z.number().default(1.2), weekly: z.number().default(0.9) }).default({}),
|
|
117
121
|
topK: z.number().default(5),
|
|
118
122
|
tokenBudget: z.number().default(1200)
|
|
119
123
|
}).default({}),
|