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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
4
4
 
5
- DSH 插件(单角色 `feiyu`)。版本 **0.15.4**。
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, 10),
87
+ favoritePerDay: num(br.favoritePerDay, 2, 0, 30),
88
88
  commentReply: br.commentReply === true,
89
- commentReplyPerDay: num(br.commentReplyPerDay, 1, 0, 5),
90
- hotComments: num(br.hotComments, 6, 0, 10),
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, 5),
93
+ commentPerDay: num(br.commentPerDay, 1, 0, 20),
94
94
  frames: br.frames === true,
95
- framesPerDay: num(br.framesPerDay, 1, 0, 5),
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
- const Num = (props) => Input({ type: "number", inputMode: "numeric", ...props, className: cx("num", props.className) });
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
- const Select = (props, ...options) => h("div", { className: "fs-affix select" },
234
- h("select", { ...props, className: "fs-input" }, ...options), Icon("chevron", 12));
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: 10, value: form.favoritePerDay, "aria-label": "一天最多收藏几个", onChange: (e) => patch({ favoritePerDay: e.target.value }), onBlur: () => save() }) : null,
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: 5, value: form.framesPerDay, "aria-label": "自己刷每天最多几条", onChange: (e) => patch({ framesPerDay: e.target.value }), onBlur: () => save() }) : null,
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: "回一条评论", stack: true, desc: "在她看的视频评论区挑一条值得搭话的回一句(楼中楼);不追着聊、不抬杠" },
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: 5, value: form.commentReplyPerDay, "aria-label": "一天最多回几条", onChange: (e) => patch({ commentReplyPerDay: e.target.value }), onBlur: () => save() }) : null,
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: { display: "block", width: "100%", marginTop: "4px" },
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: 5, value: form.commentPerDay, "aria-label": "一天最多几条", onChange: (e) => patch({ commentPerDay: e.target.value }), onBlur: () => save() }) : null,
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
  }
@@ -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 r = await api("POST", "/inspector/memory-edit", { id: m.id, content: edit.content, importance: Number(edit.importance), kind: edit.kind });
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
- (data.outdated ?? []).length === 0 ? null : Group({ title: "改掉 / 过时的", aside: Tag(String(data.outdated.length), "outline") },
142
- h("div", { className: "fs-list" }, data.outdated.map((m) => Item({
143
- key: m.id, lead: Tag(m.status === "outdated" ? "过时" : m.status === "superseded" ? "改掉" : "删了", "outline"),
144
- text: m.content, title: `${m.content}\n${m.evidence || m.source}`,
145
- meta: [Button({ key: "r", variant: "ghost", onClick: () => restore(m.id) }, "恢复")]
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 (!window.confirm(`恢复到 ${f.file}?\n重启 DSH 后生效:现在的库会先另存成 pre-restore-….db,恢复错了还能换回来。\n这份备份之后新记的东西会回到这份备份的样子。`)) return;
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 (!window.confirm(`${label}:符合当前筛选的 ${dry.matched} 条,确定?`)) return;
373
- } else if (action === "forget" && !window.confirm(`删掉选中的 ${sel.size} 条?(能在人物卡里恢复)`)) return;
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(ImportBackup, { ui, api, notify, relTime: props.relTime, onDone: load }));
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 failed++;
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,jpg,jpeg,gif,webp", multiple: true, ref: picker, style: { display: "none" }, "aria-label": "选要导入的表情图", onChange: (e) => { const files = e.target.files; e.target.value = ""; importFiles(files); } }),
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 }))));
@@ -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({}),