dsh-fish-soul 0.15.7 → 0.15.9

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.7**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.9**。
6
6
 
7
7
  QQ 交流群:**1125962189**
8
8
 
@@ -11,7 +11,7 @@ QQ 交流群:**1125962189**
11
11
  | 面 | 内容 |
12
12
  |---|---|
13
13
  | 人格 | 设置页「人格」:7 个内置模板一键换上(第一个「肥鱼 · 通用」只定性格和说话方式、生活细节聊着聊着才有,后面 6 个带现成的生活细节)、可视化编辑、导入 / 导出 SillyTavern 角色卡(PNG / JSON)、撤销上次更换,换完下一轮生效;角色卡(PNG / ccv3 / chara),`{{char}}` / `{{user}}` 宏,世界书(constant / 关键词,可单独导入多本 SillyTavern 世界书:正则 / 整词 / 次要关键词、扫描深度、条数和字数预算);卡里的作息(`extensions.fish_soul.routine`)用来排每天的日程、「看事情的方式」(`perspective`)进人格段、「不同心情下怎么说话」(`moods`)跟着她当下的心情带上;模板按气质分、不绑职业;聊久了会「慢慢长」(性格 / 看法的变化要多次、跨天、跨聊天的证据才生效,人格页可看可删);从群聊 / 好友私聊里学梗、说话风格和「什么场合怎么说」(只学别人的话、不带人名,按学到的次数挑几条参考) |
14
- | 记忆 | 按人隔离、本轮即时召回(BM25 + 向量 RRF)、艾宾浩斯衰减(重要度高不淡)、可纠正(留旧档)、出处 `evidence`、每人 ≤150 字印象、8 栏结构化印象、关系阶段演进、好感与情绪、旧聊天记录导入(断点续传,较早的聊天按时间段总结)、导入麦麦的表情包 / 表达方式 / 黑话、记忆页搜索与批量操作(删除 / 恢复 / 重要度 / 私密);**聊完自动记**(一次调用产出事实 / 事件 / 感受 + 一句话小结,去重合并);**隐私**:私聊里的敏感记忆标 `private`,群里检索和召回都不带 |
14
+ | 记忆 | 按人隔离、本轮即时召回(BM25 + 向量 RRF)、艾宾浩斯衰减(重要度高不淡)、可纠正(留旧档)、出处 `evidence`、每人 ≤150 字印象、8 栏结构化印象、关系阶段演进、好感与情绪、旧聊天记录导入(断点续传,较早的聊天按时间段总结)、导入麦麦的表情包 / 表达方式 / 黑话、记忆页搜索与批量操作(删除 / 恢复 / 重要度 / 私密)、每周压缩存档与容量告警、可选周记(见文末两节);**聊完自动记**(一次调用产出事实 / 事件 / 感受 + 一句话小结,去重合并);**隐私**:私聊里的敏感记忆标 `private`,群里检索和召回都不带 |
15
15
  | 生活 | 虚拟时钟、每日作息与基调、Dream 整理(只补没写的、会标过时)、主动开口与配额 |
16
16
  | QQ | 直连 SnowLuma(OneBot 11 正向 WS):私聊 / 群聊、群合批带署名、只听点名、戳一戳、撤回不回、可选插话、打字节奏、`/鱼 暂停`;**群默认拒绝**(`groupAllowList` 为空且没开 `allGroups` 时谁都不回)、**没发出去的回复排队重发**(退避重试,连上即补发,重启不丢) |
17
17
  | B站 | 扫码登录、私信(只读 / 自动回复)、跨平台认人、空闲自己刷视频(看字幕 / 热评 / 弹幕,喜欢可点赞、想再看可收藏,站内搜索当又一个来源,后三个默认关)、自己发动态 / 回评论(默认关;可选别人评论区里 @ 她也回,@ 时先读一下视频)、Cookie 自动续期、发图 |
@@ -35,7 +35,7 @@ dsh plugin --profile desktop add dsh-fish-soul
35
35
 
36
36
  手工等价:profile 的 `package.json` 把 `dsh-fish-soul` 加进 `dsh.profile.bundles`,并在 `dependencies` 里写 `dsh-fish-soul` 的版本(本地源码则用 `link:` 路径),然后 `pnpm install`。
37
37
 
38
- 插件注入 `webServer / tools / systemPrompt / llm / agents / workspaceRegistry`。在支持主面板的 DSH 版本中,左侧栏「鱼」可打开独立工作台;设置页入口也保留。旧宿主若不提供侧栏面板插槽,仍可从设置页打开(「人格 / 记忆 / QQ / B站 / 抖音 / 自走 / 模型 / 习惯 / 动态」)。
38
+ 插件注入 `webServer / tools / systemPrompt / llm / agents / workspaceRegistry`。在支持主面板的 DSH 版本中,左侧栏「鱼」可打开独立工作台;设置页入口也保留。旧宿主若不提供侧栏面板插槽,仍可从设置页打开(六大类:「记忆 / 角色 / 日常 / 平台 / 模型 / 系统」,每类下按任务拆子页)。
39
39
 
40
40
  已在 DSH **0.2.1-rc.5** 上验证;更早的 rc 版本未逐一验证,遇到问题请先升级复测。
41
41
 
package/lib/behavior.js CHANGED
@@ -135,6 +135,156 @@ export function stripSelfName(text, names = []) {
135
135
  return String(text ?? '').replace(new RegExp(`^([ \\t]*)(?:${esc})[ \\t]*[::][ \\t]*`, 'gm'), '$1');
136
136
  }
137
137
 
138
+ /**
139
+ * 各家模型模板的控制符(<tool_response> / <think> / <|im_end|> …)。
140
+ * 适配层偶尔剥不干净,混进正文就直接发到 QQ 上了(v0.15.1 真实翻车现场:
141
+ * 一条回复里夹着好几个 </tool_response>,正文还整段复读)。发出去之前在这里兜底:
142
+ * 成对的去掉整块;落单的去掉标签本身;未闭合的 <think> 开头到结尾整段丢(全是没写完的思考)。
143
+ */
144
+ const MODEL_TAG_NAMES = 'think|thinking|reasoning|tool_call|tool_response|function_call|function_response|function|parameter';
145
+ const TAG_BLOCK_RE = new RegExp(`<(${MODEL_TAG_NAMES})\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>`, 'gi');
146
+ const TAG_OPEN_THINK_RE = new RegExp(`<(?:think|thinking|reasoning)\\b[^>]*>[\\s\\S]*$`, 'i');
147
+ const TAG_STRAY_RE = new RegExp(`<\\/?(?:${MODEL_TAG_NAMES})\\b[^>]*>`, 'gi');
148
+ const SPECIAL_TOKEN_RE = /<\|[^|>\n]{1,40}\|>/g;
149
+
150
+ /** 剥掉漏进正文的模型模板标签:返回 { text, changed }。 */
151
+ export function stripModelTags(input) {
152
+ const raw = String(input ?? '');
153
+ let t = raw;
154
+ // 成对块整块删;可能连着好几个,循环到稳定(限轮数防呆)
155
+ for (let i = 0; i < 8; i++) {
156
+ const next = t.replace(TAG_BLOCK_RE, '');
157
+ if (next === t) break;
158
+ t = next;
159
+ }
160
+ // 未闭合的思考块:开标签之后全是没写完的思考,整段丢
161
+ t = t.replace(TAG_OPEN_THINK_RE, '');
162
+ // 落单的标签、<|im_start|> 这类特殊符:只删标签本身,正文留着
163
+ t = t.replace(TAG_STRAY_RE, '').replace(SPECIAL_TOKEN_RE, '');
164
+ // changed 只在真剥到标签 / 特殊符时为 true(空白规整不算,免得动态面板留噪音)
165
+ const hadTags = raw.replace(TAG_BLOCK_RE, '').replace(TAG_OPEN_THINK_RE, '').replace(TAG_STRAY_RE, '').replace(SPECIAL_TOKEN_RE, '') !== raw;
166
+ // 删完留下的空行 / 空白残渣
167
+ t = t.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').replace(/^[ \t]+$/gm, '').trim();
168
+ return { text: t, changed: hadTags };
169
+ }
170
+
171
+ /** 规整化(比"是不是同一句换了个说法"用):去空白和标点、转小写。 */
172
+ const normPara = (s) => String(s ?? '').toLowerCase()
173
+ .replace(/[\s。,、!?!?~~…·,.:;:""'“”‘’「」『』()()【】\[\]*#\-—|]/g, '');
174
+
175
+ /** 两段规整化文字像不像(0..1):字符二元组 Dice 系数。 */
176
+ function bigramDice(a, b) {
177
+ if (a === b) return 1;
178
+ if (a.length < 2 || b.length < 2) return 0;
179
+ const grams = (s) => {
180
+ const m = new Map();
181
+ for (let i = 0; i < s.length - 1; i++) { const g = s.slice(i, i + 2); m.set(g, (m.get(g) ?? 0) + 1); }
182
+ return m;
183
+ };
184
+ const ga = grams(a);
185
+ let inter = 0;
186
+ for (const [g, n] of grams(b)) inter += Math.min(n, ga.get(g) ?? 0);
187
+ return (2 * inter) / ((a.length - 1) + (b.length - 1));
188
+ }
189
+
190
+ /**
191
+ * 模型复读兜底:同一轮里后面出现的段落跟前面某段几乎一样(换个说法也算),只留第一份。
192
+ * 规整化后 ≥12 字才按相似度 ≥0.75 算重复(短句改一个字 Dice 就超 0.75,会误杀
193
+ * 「你今天几点睡的 / 你今天几点睡呀」这种正常表达,所以门槛跟跨条守卫对齐);
194
+ * 3~11 字的短段只有一字不差才算("哈哈哈哈"这种故意重复不误杀)。
195
+ * 没有重复时原文原样返回(不动格式)。返回 { text, dropped }。
196
+ */
197
+ export function dedupeParagraphs(input, { threshold = 0.75, minLen = 12, exactLen = 3 } = {}) {
198
+ const raw = String(input ?? '');
199
+ const paras = raw.split(/\n{2,}/);
200
+ const out = [];
201
+ const seen = [];
202
+ let dropped = 0;
203
+ for (const p of paras) {
204
+ const n = normPara(p);
205
+ const len = [...n].length;
206
+ const dup = len >= minLen ? seen.some((k) => bigramDice(k, n) >= threshold)
207
+ : len >= exactLen && seen.includes(n);
208
+ if (dup) { dropped++; continue; }
209
+ seen.push(n);
210
+ out.push(p);
211
+ }
212
+ if (dropped === 0) return { text: raw, dropped: 0 };
213
+ return { text: out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim(), dropped };
214
+ }
215
+
216
+ /**
217
+ * 超长单段兜底:模型偶尔把整条回复挤成一段还不带空行(人设又不用标点,
218
+ * 按句末标点拆条也拆不动),到 QQ 上就成了一堵长消息。这里把超过 maxLen 字的
219
+ * 单行段按空格匀着拆成几截(最多 maxParts 截),截之间空一行(发送端按空行拆条)。
220
+ * 多行的段、[语音] 段、没有空格的长段不动。返回 { text, changed, parts },parts 是多拆出来的条数。
221
+ */
222
+ export function splitLongParagraphs(input, { maxLen = 60, maxParts = 3 } = {}) {
223
+ const raw = String(input ?? '');
224
+ const paras = raw.split(/\n{2,}/);
225
+ const out = [];
226
+ let changed = false;
227
+ let parts = 0;
228
+ for (const p of paras) {
229
+ const t = p.trim();
230
+ if (t === '' || t.includes('\n') || /^\[语音\]/.test(t) || !t.includes(' ') || [...t].length <= maxLen) { out.push(p); continue; }
231
+ const words = t.split(/ +/).filter((w) => w !== '');
232
+ if (words.length < 2) { out.push(p); continue; }
233
+ const total = [...t].length;
234
+ const n = Math.min(maxParts, Math.max(2, Math.ceil(total / maxLen)));
235
+ const target = Math.ceil(total / n) + 2; // 留点余量,别让最后一个词溢出成多余的一截
236
+ const chunks = [];
237
+ let cur = words[0];
238
+ for (const w of words.slice(1)) {
239
+ const joined = cur + ' ' + w;
240
+ if ([...joined].length <= target) cur = joined;
241
+ else { chunks.push(cur); cur = w; }
242
+ }
243
+ chunks.push(cur);
244
+ // 贪心拆出来太多截时,相邻的均匀并回去,并到 n 截
245
+ if (chunks.length > n) {
246
+ const groups = [];
247
+ let i = 0;
248
+ for (let g = 0; g < n; g++) {
249
+ const size = Math.ceil((chunks.length - i) / (n - g));
250
+ groups.push(chunks.slice(i, i + size).join(' '));
251
+ i += size;
252
+ }
253
+ chunks.length = 0;
254
+ chunks.push(...groups);
255
+ }
256
+ if (chunks.length > 1) {
257
+ changed = true;
258
+ parts += chunks.length - 1;
259
+ out.push(...chunks);
260
+ } else out.push(p);
261
+ }
262
+ if (!changed) return { text: raw, changed: false, parts: 0 };
263
+ return { text: out.join('\n\n'), changed: true, parts };
264
+ }
265
+
266
+ /** "最近发过的话"的规整化:去掉 [语音] 标记再规整,跨条复读的判定和记录共用。 */
267
+ export function normSentText(s) {
268
+ return normPara(String(s ?? '').replace(/\[语音\]/g, ''));
269
+ }
270
+
271
+ /**
272
+ * 跨条复读守卫(纯逻辑):模型遇到相近的输入时偶尔把自己上一条消息一字不差地再发一遍
273
+ * (2026-11-06 QQ 真实翻车:同一段内容隔 17 秒 / 43 秒各发了一次)。
274
+ * history: [{ at, norm }](最近发出去的,norm 已规整化);paragraphs 里规整化后
275
+ * ≥ minLen 字、且跟窗口 windowMs 内某条完全一致的,返回它们的下标。
276
+ * 短句允许重复("哈哈哈哈"这种不该误杀)。
277
+ */
278
+ export function findRepeatedParagraphs(history, paragraphs, { now = Date.now(), windowMs = 120000, minLen = 12 } = {}) {
279
+ const recent = (Array.isArray(history) ? history : []).filter((h) => now - Number(h?.at ?? 0) <= windowMs);
280
+ const dups = [];
281
+ (Array.isArray(paragraphs) ? paragraphs : []).forEach((p, i) => {
282
+ const n = normSentText(p);
283
+ if ([...n].length >= minLen && recent.some((h) => h.norm === n)) dups.push(i);
284
+ });
285
+ return dups;
286
+ }
287
+
138
288
  /**
139
289
  * 去 AI 味(只改写 / 删 / 截短,不撤回):返回 { text, changes }。
140
290
  * 改完变成空的(整段都是套话 / AI 自称)返回空字符串,调用方这轮就不发。
@@ -486,10 +636,8 @@ export class DiaryWriter {
486
636
  AND created_at >= ? AND created_at < ? ORDER BY created_at LIMIT 10`).all(start, end);
487
637
  const recent = db.prepare(`SELECT content FROM memory WHERE character_id = 'feiyu' AND owner_id = 'self' AND kind = 'diary' AND status = 'active'
488
638
  AND created_at < ? ORDER BY created_at DESC LIMIT 3`).all(start).map((x) => x.content.replace(/^[\d-]+ 的日记:/, '').replace(/\s+/g, ' ').slice(0, 120));
489
- const snap = this.life?.stateSnapshot?.() ?? null;
490
- const plan = snap?.today?.date === date ? snap.today : null;
639
+ // 日记只写真实发生的事:自走排程是虚拟计划不是经历,不进日记材料(2026-11-06 主人反馈)。
491
640
  return {
492
- tone: plan?.dailyMoodTone ?? '', schedule: (plan?.scheduleSummary ?? []).map((s) => `${s.range ?? ''} ${s.activity ?? ''}`.trim()),
493
641
  episodes: eps.map((e) => `${e.name ? `和${e.name}:` : ''}${e.content}`), self: self.map((s) => s.content), recent
494
642
  };
495
643
  }
@@ -520,8 +668,6 @@ export class DiaryWriter {
520
668
  `- 不要提 AI、模型、系统这些词,也不要任何关于「写日记」这件事本身的说明。\n` +
521
669
  `- 日期写 ${date} 就行,不要用「今天」开头的套话开头。\n` +
522
670
  (mat.recent.length ? `最近几篇日记(换一个新角度写,除非今天有新的事,别重复之前的写法和切入点):\n${mat.recent.map((e) => `- ${e}`).join('\n')}\n` : '') +
523
- (mat.tone ? `今天的基调:${mat.tone}\n` : '') +
524
- (mat.schedule.length ? `今天的安排:${mat.schedule.join(';')}\n` : '') +
525
671
  `今天聊过的:\n${mat.episodes.map((e) => `- ${e}`).join('\n') || '(没怎么聊天)'}\n` +
526
672
  (mat.self.length ? `今天自己说过的事:\n${mat.self.map((e) => `- ${e}`).join('\n')}\n` : '') +
527
673
  '今天实在平淡,日记就写一两句平淡本身也行,不要硬凑。\n' +
@@ -156,7 +156,7 @@ window.__ModuleLoader__.load({
156
156
  h("span", { className: "fs-item-meta" }, h("span", null, relTime(e.at, now)))))));
157
157
  }
158
158
 
159
- function BehaviorPane({ api, notify, relTime, ui }) {
159
+ function BehaviorPane({ api, notify, relTime, ui, section = "habits" }) {
160
160
  const { h, Empty, Dot, Group } = ui;
161
161
  const [data, load] = useLoad(api, "/behavior/state");
162
162
  if (data === null) return Empty(Dot("ongoing"));
@@ -167,11 +167,11 @@ window.__ModuleLoader__.load({
167
167
  }
168
168
  const groups = data.groups ?? [];
169
169
  return h(react.Fragment, null,
170
- h(Habits, { cfg: data.config, save, ui }),
171
- Group({ title: "每个群" }, groups.length === 0 ? Empty("还没有群(在 QQ 页填群白名单,或者等群里有人叫她)")
172
- : groups.map((g) => h(GroupNote, { key: g.groupId, g, api, notify, onSaved: load, ui }))),
173
- h(ChatModels, { data, api, notify, load, ui }),
174
- h(Diary, { data, save, api, notify, load, relTime, ui }));
170
+ section === "habits" ? h(Habits, { cfg: data.config, save, ui }) : null,
171
+ section === "groups" ? Group({ title: "每个群" }, groups.length === 0 ? Empty("还没有群(在 QQ 页填群白名单,或者等群里有人叫她)")
172
+ : groups.map((g) => h(GroupNote, { key: g.groupId, g, api, notify, onSaved: load, ui }))) : null,
173
+ section === "models" ? h(ChatModels, { data, api, notify, load, ui }) : null,
174
+ section === "diary" ? h(Diary, { data, save, api, notify, load, relTime, ui }) : null);
175
175
  }
176
176
 
177
177
  return { BehaviorPane };
package/lib/client.js CHANGED
@@ -152,33 +152,18 @@ window.__ModuleLoader__.load({
152
152
  }, h("span", { className: "fs-tab-key", "aria-hidden": true }, TAB_KEYS[v] ?? label.slice(0, 1)), h("span", { className: "fs-tab-label" }, label), v === "version" && hasUpdate ? h("span", { className: "fs-update-dot", "aria-hidden": true }) : null)));
153
153
  }
154
154
 
155
- // 两级导航保留原有页面,按任务分类;引导跳转仍以原页签 id 为真源。
156
- function GroupedTabs({ items, value, onChange, hasUpdate }) {
157
- const groups = NAV_GROUPS.map((group) => ({ ...group, items: items.filter(([id]) => group.ids.includes(id)) })).filter((group) => group.items.length > 0);
158
- const active = groups.find((group) => group.items.some(([id]) => id === value)) ?? groups[0];
159
- const remembered = react.useRef({});
160
- const groupRef = react.useRef(null);
161
- react.useEffect(() => {
162
- if (active) remembered.current[active.id] = value;
163
- const nav = groupRef.current;
164
- const selected = nav?.querySelector('[aria-pressed="true"]');
165
- if (!nav || !selected) return;
166
- const outer = nav.getBoundingClientRect(), inner = selected.getBoundingClientRect();
167
- if (inner.left < outer.left) nav.scrollLeft -= outer.left - inner.left;
168
- else if (inner.right > outer.right) nav.scrollLeft += inner.right - outer.right;
169
- }, [value]);
170
- return h(react.Fragment, null,
171
- h("div", { className: "fs-group-row" },
172
- h("span", { className: "fs-brand" }, "鱼"),
173
- h("nav", { className: "fs-group-nav", ref: groupRef, "aria-label": "功能分类" }, groups.map((group) => {
174
- const selected = group.id === active?.id;
175
- const showUpdate = hasUpdate && group.ids.includes("version");
176
- return h("button", { type: "button", key: group.id, className: "fs-group-tab", "aria-pressed": selected,
177
- "aria-label": showUpdate ? `${group.label}(有新版本)` : group.label,
178
- onClick: () => onChange(remembered.current[group.id] ?? group.items[0][0]) },
179
- group.label, showUpdate ? h("span", { className: "fs-update-dot", "aria-hidden": true }) : null);
180
- }))),
181
- h("div", { className: "fs-subnav-row" }, h(Tabs, { items: active?.items ?? [], value, onChange, horizontal: true, hasUpdate })));
155
+ // 大项是页面,不再让每个大项重复一个同名子页签。
156
+ function GroupedTabs({ value, onChange, hasUpdate }) {
157
+ const active = NAV_GROUPS.find((group) => group.id === value) ?? NAV_GROUPS[0];
158
+ return h("div", { className: "fs-group-row" },
159
+ h("span", { className: "fs-brand" }, "鱼"),
160
+ h("nav", { className: "fs-group-nav", "aria-label": "设置分类" }, NAV_GROUPS.map((group) => {
161
+ const selected = group.id === active.id;
162
+ const showUpdate = hasUpdate && group.id === "system";
163
+ return h("button", { type: "button", key: group.id, className: "fs-group-tab", "aria-pressed": selected,
164
+ "aria-label": showUpdate ? `${group.label}(有新版本)` : group.label, onClick: () => onChange(group.id) },
165
+ group.label, showUpdate ? h("span", { className: "fs-update-dot", "aria-hidden": true }) : null);
166
+ })));
182
167
  }
183
168
 
184
169
  /** StateDot:done / warning / error / idle 是圆点,ongoing 是转圈。 */
@@ -763,16 +748,18 @@ window.__ModuleLoader__.load({
763
748
  }
764
749
 
765
750
  // ── 面板根组件:页签 + 数据装载 + 轮询 ──
766
- const TAB_KEYS = { guide: "引", retrieval: "索", persona: "人", life: "自", inspector: "忆", activity: "动", behavior: "习", models: "模", qq: "Q", bili: "B", douyin: "抖", version: "版" };
767
- const TABS = [["retrieval", "记忆检索"], ["persona", "人格"], ["life", "自走"], ["inspector", "记忆检视"], ["activity", "动态"], ["behavior", "习惯"], ["models", "模型"], ["qq", "QQ"], ["bili", "B站"], ["douyin", "抖音"]];
768
- const NAV_GROUPS = [
769
- { id: "start", label: "开始", ids: ["guide"] },
770
- { id: "memory", label: "记忆", ids: ["retrieval", "inspector"] },
771
- { id: "character", label: "角色", ids: ["persona", "behavior"] },
772
- { id: "daily", label: "日常", ids: ["life", "activity"] },
773
- { id: "channels", label: "平台", ids: ["qq", "bili", "douyin"] },
774
- { id: "system", label: "系统", ids: ["models", "interface", "version"] }
751
+ const TAB_KEYS = { memory: "忆", character: "角", daily: "常", channels: "台", models: "模", system: "统", guide: "引", retrieval: "索", persona: "人", life: "自", inspector: "忆", activity: "动", behavior: "习", qq: "Q", bili: "B", douyin: "抖", version: "版" };
752
+ const TABS = [
753
+ ["memory", "记忆", [["retrieval", "检索设置"], ["people", "人物与关系"], ["memories", "记忆内容"], ["recall-test", "检索测试"], ["weekly", "周记"], ["archives", "记忆存档"], ["backups", "导入与备份"]]],
754
+ ["character", "角色", [["persona", "人格卡"], ["templates", "人格模板"], ["growth", "成长"], ["worldinfo", "世界书"], ["habits", "说话习惯"], ["groups", "群设定"], ["chat-models", "聊天模型"]]],
755
+ ["daily", "日常", [["life", "主动开口"], ["activity", "最近动态"], ["diary", "日记"], ["stickers", "表情包"], ["slang", "学到的说法"]]],
756
+ ["channels", "平台", [["qq", "QQ"], ["bili", "B站"], ["douyin", "抖音"]]],
757
+ ["models", "模型", [["models", "功能模型"], ["usage", "用量与价格"], ["web", "联网搜索"], ["voice", "语音"]]],
758
+ ["system", "系统", [["interface", "界面"], ["version", "版本更新"]]]
775
759
  ];
760
+ const NAV_GROUPS = TABS.map(([id, label]) => ({ id, label }));
761
+ const SUBTABS = Object.fromEntries(TABS.map(([id, , subs]) => [id, subs]));
762
+ const DEFAULT_TABS = Object.fromEntries(TABS.map(([id, , subs]) => [id, subs[0][0]]));
776
763
 
777
764
  // 服务端仍提供原始步骤与状态;短标题只调整展示,未知步骤保留服务端原文。
778
765
  const ONBOARDING_LABELS = { persona: "选择人格", model: "配置模型", qq: "连接 QQ", master: "设置主人 QQ", retrieval: "配置向量检索", import: "导入聊天记录", voice: "配置语音" };
@@ -859,9 +846,21 @@ window.__ModuleLoader__.load({
859
846
  const guideVisibilityKey = "fish-soul:guide-visible";
860
847
  const readGuideVisible = () => { try { return window.localStorage.getItem(guideVisibilityKey) !== "false"; } catch { return true; } };
861
848
  const [guideVisible, setGuideVisible] = react.useState(readGuideVisible);
862
- const [tab, setTab] = react.useState("retrieval");
849
+ const [tab, setTab] = react.useState("memory");
850
+ const [subtab, setSubtab] = react.useState("retrieval");
863
851
  const hasInteracted = react.useRef(false);
864
- const goTab = (next) => { hasInteracted.current = true; setTab(next); };
852
+ const remembered = react.useRef({});
853
+ const goTab = (next, child) => {
854
+ hasInteracted.current = true;
855
+ if (next === "guide") { setTab(next); return; }
856
+ if (SUBTABS[next]) setSubtab(child ?? remembered.current[next] ?? DEFAULT_TABS[next]);
857
+ setTab(next);
858
+ };
859
+ const goSubtab = (next) => { hasInteracted.current = true; remembered.current[tab] = next; setSubtab(next); };
860
+ const goSetting = (next) => {
861
+ const group = TABS.find(([, , subs]) => subs.some(([id]) => id === next));
862
+ if (group) goTab(group[0], next);
863
+ };
865
864
  const [retrieval, setRetrieval] = react.useState(null);
866
865
  const [life, setLife] = react.useState(null);
867
866
  const [channels, setChannels] = react.useState(null);
@@ -873,7 +872,7 @@ window.__ModuleLoader__.load({
873
872
  try { window.localStorage.setItem(guideVisibilityKey, String(visible)); }
874
873
  catch { notify("无法保存界面设置"); return; }
875
874
  setGuideVisible(visible);
876
- if (!visible) setTab((current) => current === "guide" ? "retrieval" : current);
875
+ if (!visible) setTab((current) => current === "guide" ? "memory" : current);
877
876
  window.dispatchEvent(new Event("fish-soul:guide-visibility"));
878
877
  };
879
878
  react.useEffect(() => {
@@ -904,14 +903,14 @@ window.__ModuleLoader__.load({
904
903
  }, []);
905
904
  // 检索页 2s 刷新(重建进度);QQ / B站 / 抖音 3s 刷新
906
905
  react.useEffect(() => {
907
- const fn = tab === "retrieval" ? loadRetrieval : tab === "qq" || tab === "bili" || tab === "douyin" ? loadChannels : null;
906
+ const fn = subtab === "retrieval" ? loadRetrieval : subtab === "qq" || subtab === "bili" || subtab === "douyin" ? loadChannels : null;
908
907
  if (fn === null) return undefined;
909
- const timer = setInterval(fn, tab === "retrieval" ? 2000 : 3000);
908
+ const timer = setInterval(fn, subtab === "retrieval" ? 2000 : 3000);
910
909
  return () => clearInterval(timer);
911
- }, [tab]);
910
+ }, [subtab]);
912
911
  // 虚拟时钟走秒(纯展示)
913
912
  react.useEffect(() => {
914
- if (tab !== "life") return undefined;
913
+ if (subtab !== "life") return undefined;
915
914
  const timer = setInterval(() => setLife((prev) => prev?.clock?.virtualNowMs
916
915
  ? { ...prev, clock: { ...prev.clock, virtualNowMs: prev.clock.virtualNowMs + 1000 * (prev.clock.timeScale ?? 1) } } : prev), 1000);
917
916
  return () => clearInterval(timer);
@@ -926,7 +925,7 @@ window.__ModuleLoader__.load({
926
925
 
927
926
  // 导航红点使用同一只读状态接口;版本页自身检查后的结果即时同步。
928
927
  react.useEffect(() => {
929
- if (tab === "version") return undefined;
928
+ if (subtab === "version") return undefined;
930
929
  let active = true;
931
930
  const loadUpdate = async () => { const r = await api("GET", "/update/state"); if (active && r?.ok) setUpdateAvailable(r.hasUpdate === true); };
932
931
  loadUpdate();
@@ -935,30 +934,31 @@ window.__ModuleLoader__.load({
935
934
  }, [tab]);
936
935
 
937
936
  if (css === null) return null;
938
- const pane = tab === "guide" ? h(Onboarding, { onGo: goTab, onDismiss: () => goTab("retrieval"), onHide: () => setGuideVisibility(false), notify })
939
- : tab === "interface" ? Row({ title: "显示开始" }, h(Switch, { label: "显示开始", checked: guideVisible, onChange: setGuideVisibility }))
940
- : tab === "version" ? h(Boundary, { name: "版本" }, h(UpdateBar, { notify, onUpdateState: setUpdateAvailable }))
941
- : tab === "retrieval" ? h(RetrievalPane, { data: retrieval, refresh: loadRetrieval, notify })
942
- : tab === "life" ? h(LifePane, { data: life, refresh: loadLife, notify })
943
- : tab === "inspector" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.InspectorPane, failText: "记忆检视加载失败", notify })
944
- : tab === "activity" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.ActivityPane, failText: "动态加载失败", notify })
945
- : tab === "behavior" ? h(ChunkTab, { file: "client.behavior.js", pick: (m) => m.BehaviorPane, failText: "习惯页加载失败", notify })
946
- : tab === "models" ? h(ChunkTab, { file: "client.models.js", pick: (m) => m.ModelsPane, failText: "模型页加载失败", notify })
947
- : tab === "persona" ? h(ChunkTab, { file: "client.persona.js", pick: (m) => m.PersonaPane, failText: "人格页加载失败", notify })
948
- : tab === "qq" ? h(ChannelPane, { data: channels, refresh: loadChannels, notify })
949
- : tab === "bili" ? h(BiliPane, { data: channels, refresh: loadChannels, notify })
937
+ const pane = tab === "guide" ? h(Onboarding, { onGo: goSetting, onDismiss: () => goTab("memory"), onHide: () => setGuideVisibility(false), notify })
938
+ : subtab === "interface" ? Row({ title: "显示开始" }, h(Switch, { label: "显示开始", checked: guideVisible, onChange: setGuideVisibility }))
939
+ : subtab === "version" ? h(Boundary, { name: "版本" }, h(UpdateBar, { notify, onUpdateState: setUpdateAvailable }))
940
+ : subtab === "retrieval" ? h(RetrievalPane, { data: retrieval, refresh: loadRetrieval, notify })
941
+ : subtab === "life" ? h(LifePane, { data: life, refresh: loadLife, notify })
942
+ : ["people", "memories", "recall-test", "weekly", "archives", "backups"].includes(subtab) ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.InspectorPane, failText: "记忆管理加载失败", section: subtab, notify })
943
+ : ["activity", "stickers", "slang"].includes(subtab) ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.ActivityPane, failText: "动态加载失败", section: subtab, notify })
944
+ : ["habits", "groups", "chat-models", "diary"].includes(subtab) ? h(ChunkTab, { file: "client.behavior.js", pick: (m) => m.BehaviorPane, failText: "习惯页加载失败", section: subtab === "chat-models" ? "models" : subtab, notify })
945
+ : ["models", "usage", "web", "voice"].includes(subtab) ? h(ChunkTab, { file: "client.models.js", pick: (m) => m.ModelsPane, failText: "模型页加载失败", section: subtab, notify })
946
+ : ["persona", "templates", "growth", "worldinfo"].includes(subtab) ? h(ChunkTab, { file: "client.persona.js", pick: (m) => m.PersonaPane, failText: "人格页加载失败", section: subtab === "persona" ? "current" : subtab, notify })
947
+ : subtab === "qq" ? h(ChannelPane, { data: channels, refresh: loadChannels, notify })
948
+ : subtab === "bili" ? h(BiliPane, { data: channels, refresh: loadChannels, notify })
950
949
  : h(ChunkTab, { file: "client.douyin.js", pick: (m) => m.DouyinPane, failText: "抖音页加载失败", data: channels, refresh: loadChannels, notify });
951
950
 
952
- // 工作台:主页与设置页都把版本更新作为系统分组下的独立页签。
953
- const tabs = [...(guideVisible ? [["guide", "使用引导"]] : []), ...TABS, ["interface", "界面"], ["version", "版本更新"]];
954
- const label = tabs.find(([v]) => v === tab)?.[1] ?? "";
951
+ const tabs = tab === "guide" ? [] : SUBTABS[tab] ?? [];
952
+ const label = tab === "guide" ? "使用引导" : tabs.find(([v]) => v === subtab)?.[1] ?? "";
955
953
  return h("div", { className: "fs-host" }, h("div", { className: cx("fs", standalone ? "fs-standalone" : "fs-settings"), onPointerDown: () => { hasInteracted.current = true; }, onKeyDown: () => { hasInteracted.current = true; } },
956
954
  css === "" ? null : h("style", { "data-plugin-css": `${PLUGIN_ID}/panel.css` }, css),
957
955
  h("div", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
958
- h(GroupedTabs, { items: tabs, value: tab, onChange: goTab, hasUpdate: updateAvailable }))),
956
+ h(GroupedTabs, { value: tab, onChange: goTab, hasUpdate: updateAvailable }),
957
+ guideVisible && tab !== "guide" ? h("div", { className: "fs-subnav-row" }, Button({ variant: "ghost", onClick: () => goTab("guide") }, "使用引导")) : null,
958
+ tabs.length > 0 ? h("div", { className: "fs-subnav-row" }, h(Tabs, { items: tabs, value: subtab, onChange: goSubtab, horizontal: true })) : null)),
959
959
  h("main", { className: "fs-main" },
960
- 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),
961
- h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${tab}`, key: tab }, h(Boundary, { name: "这一页" }, pane))),
960
+ 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: () => goSetting("archives") }, storageWarning)) : null),
961
+ h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${subtab}`, key: `${tab}:${subtab}` }, h(Boundary, { name: "这一页" }, pane))),
962
962
  toast === null ? null : h("div", { className: "fs-toast", role: "status", key: toast.key }, toast.text)));
963
963
  }
964
964
 
@@ -199,13 +199,17 @@ window.__ModuleLoader__.load({
199
199
  tts.hasKey ? Row({ title: "试听" }, Input({ value: say, onChange: (e) => setSay(e.target.value), "aria-label": "试听的话" }), Button({ disabled: busy, onClick: test }, busy ? "合成中" : "播放")) : null) : null);
200
200
  }
201
201
 
202
- function ModelsPane({ api, notify, ui }) {
202
+ function ModelsPane({ api, notify, ui, section = "models" }) {
203
203
  const { h, Empty, Dot } = ui;
204
204
  const [data, load] = useLoad(api, "/models/state");
205
205
  if (data === null) return Empty(Dot("ongoing"));
206
206
  if (data.error) return h("div", { className: "fs-notice", role: "alert" }, h("b", null, "模型状态读不到"), h("span", null, data.error));
207
207
  const p = { data, api, notify, load, ui };
208
- return h(react.Fragment, null, h(FeatureModels, p), h(Usage, p), h(Web, p), h(Voice, { api, notify, ui }));
208
+ return h(react.Fragment, null,
209
+ section === "models" ? h(FeatureModels, p) : null,
210
+ section === "usage" ? h(Usage, p) : null,
211
+ section === "web" ? h(Web, p) : null,
212
+ section === "voice" ? h(Voice, { api, notify, ui }) : null);
209
213
  }
210
214
 
211
215
  return { ModelsPane };
@@ -203,7 +203,7 @@ window.__ModuleLoader__.load({
203
203
  confirmNode);
204
204
  }
205
205
 
206
- function PersonaPane({ api, notify, ui }) {
206
+ function PersonaPane({ api, notify, ui, section = "current" }) {
207
207
  const { h, Group, Row, Button, Switch, Tag, Muted, Empty, Dot } = ui;
208
208
  const [data, setData] = useLoad(api, "/persona/state");
209
209
  const [open, setOpen] = react.useState(null);
@@ -264,7 +264,7 @@ window.__ModuleLoader__.load({
264
264
  if (editing !== null) return h(Editor, { h, ui, card: editing, busy, onSave: saveCard, onCancel: () => setEditing(null) });
265
265
 
266
266
  return h(react.Fragment, null,
267
- Group({ title: "现在的人格", aside: data.canUndo ? Button({ variant: "ghost", disabled: busy, onClick: undo }, "撤销上次更换") : null },
267
+ section === "current" ? Group({ title: "现在的人格", aside: data.canUndo ? Button({ variant: "ghost", disabled: busy, onClick: undo }, "撤销上次更换") : null },
268
268
  Row({ title: "名字", desc: cur.source === "persona.png" ? "从 persona.png 读的;在这里改了会另存成 persona.json" : undefined },
269
269
  h("b", null, cur.name), Tag(SOURCE[cur.source] ?? cur.source, cur.source === "builtin" ? "outline" : undefined)),
270
270
  cur.source !== "builtin" ? Row({ title: "一句话人设", stack: true }, h("p", { className: "fs-persona-desc" }, cur.anchor || "没写(会用性格那一栏顶上)")) : null,
@@ -276,16 +276,16 @@ window.__ModuleLoader__.load({
276
276
  cur.card ? Button({ variant: "ghost", onClick: exportCard }, "导出") : null,
277
277
  h("input", { ref: fileRef, type: "file", accept: ".json,.png,application/json,image/png", hidden: true, onChange: pickFile })),
278
278
  Row({ title: "正在聊的也马上换", desc: freshOn ? "下一条消息起开新会话,旧聊天的口吻不带过来;记忆和关系都还在" : "沿用旧会话:新人格下一轮生效,但口吻可能要几轮才转过来" },
279
- h(Switch, { label: "正在聊的也马上换", checked: freshOn, onChange: toggleFresh }))),
280
- Group({ title: "人格模板" },
279
+ h(Switch, { label: "正在聊的也马上换", checked: freshOn, onChange: toggleFresh }))) : null,
280
+ section === "templates" ? Group({ title: "人格模板" },
281
281
  ...(data.templates ?? []).map((t) => h(Template, {
282
282
  key: t.id, t, h, ui, open: open === t.id, current: cur.template === t.id && cur.source === "persona.json",
283
283
  onOpen: () => setOpen(open === t.id ? null : t.id),
284
284
  onApply: () => applyTemplate(t),
285
285
  onEdit: () => setEditing({ ...t.card, data: { ...t.card.data, extensions: { ...t.card.data.extensions, fish_soul: { ...t.card.data.extensions?.fish_soul, template: t.id } } } })
286
- }))),
287
- h(Growth, { h, ui, growth: data.growth, post: growthPost, busy }),
288
- h(WorldInfo, { api, notify, ui }));
286
+ }))) : null,
287
+ section === "growth" ? h(Growth, { h, ui, growth: data.growth, post: growthPost, busy }) : null,
288
+ section === "worldinfo" ? h(WorldInfo, { api, notify, ui }) : null);
289
289
  }
290
290
 
291
291
  return { PersonaPane };
@@ -154,8 +154,17 @@ window.__ModuleLoader__.load({
154
154
  prof?.locked ? Button({ variant: "ghost", disabled: busy, onClick: () => save({ profileLocked: false }, "交还给 Dream 了") }, "交给 Dream") : null,
155
155
  Button({ variant: "ghost", disabled: busy, onClick: dream }, "现在整理一次"),
156
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),
157
+ p.canonicalId !== "master" && p.canonicalId !== "self" ? Row({ title: "清空记忆", desc: "删掉这个人的全部记忆(人还在,之后还能聊)" },
158
+ Button({ variant: "ghost", disabled: busy, onClick: forgetAll }, "全部删掉")) : null,
159
+ // #68:删除整个人物卡(不只是清空记忆——人本身从记忆区里消失)
160
+ p.canonicalId !== "master" && p.canonicalId !== "self" ? Row({ title: "删除这个人", desc: "把这张人物卡整个删掉(记忆可在「改掉 / 过时的」里恢复,人物卡不可恢复)" },
161
+ Button({ variant: "outline", disabled: busy, onClick: async () => {
162
+ if (!(await confirm({ title: `删除「${p.displayName}」这张人物卡?`, description: `这个人在记忆区里的卡片会消失,\n所有记忆标为已删(可恢复)、待办丢弃、心情印象清空。\n人物卡本身不可恢复。`, danger: true }))) return;
163
+ setBusy(true);
164
+ const r = await api("POST", "/inspector/person/delete", { ownerId });
165
+ setBusy(false);
166
+ if (r?.ok) { notify("已删除这个人物卡"); onChanged(); } else notify(r?.error ?? "删除失败");
167
+ } }, "删除这张卡")) : null),
159
168
  (data.episodes ?? []).length === 0 ? null : Group({ title: "最近聊过" },
160
169
  h("div", { className: "fs-list" }, data.episodes.map((x) => Item({ key: x.id, text: x.content, meta: [h("span", { key: "t" }, fmtDay(x.at))] })))),
161
170
  (data.outdated ?? []).length === 0 ? null : Group({ title: "改掉 / 过时的", aside: Tag(String(data.outdated.length), "outline") },
@@ -443,9 +452,9 @@ window.__ModuleLoader__.load({
443
452
  confirmNode);
444
453
  }
445
454
 
446
- function InspectorPane(props) {
455
+ function InspectorPane({ section = "memories", ...props }) {
447
456
  const { api, notify, ui } = props;
448
- const { h, Tag, Empty, Item, Muted, Group, Input, Button, useConfirm } = ui;
457
+ const { h, Tag, Empty, Item, Muted, Group, Row, Input, Button, useConfirm } = ui;
449
458
  const [owner, setOwner] = react.useState("all");
450
459
  const [q, setQ] = react.useState("");
451
460
  const [qApplied, setQApplied] = react.useState("");
@@ -498,16 +507,22 @@ window.__ModuleLoader__.load({
498
507
  if (r?.ok) { setAdding(""); notify("已记下"); load(); } else notify(r?.error ?? "添加失败");
499
508
  }
500
509
  return h(react.Fragment, null,
501
- h("section", { className: "fs-group" },
502
- h("div", { className: "fs-group-head" },
503
- h("div", { className: "fs-pills", role: "group", "aria-label": "按人筛选" }, pill("all", "全部"),
504
- ...persons.map((p) => pill(p.ownerId, p.ownerId === "self" ? "她自己" : p.displayName, p.ownerId === "self" ? undefined : `${stageLabel(p.relationStage)} · ${p.memoryCount} 条`))),
505
- Tag(String(memories.length), "outline"))),
506
- owner !== "all" && owner !== "self" ? h(PersonCard, { key: owner, ownerId: owner, ui, api, notify, onChanged: load }) : null,
507
- owner !== "all" && owner !== "self" ? h(MemoryDebug, { key: `dbg-${owner}`, ownerId: owner, ui, api, notify }) : null,
508
- Group({ title: owner === "all" ? "最近的记忆" : "记忆", aside: h("span", { className: "fs-group-aside" },
510
+ section === "people" ? Group({ title: "人物与关系" },
511
+ Row({ title: "选择人物" }, ui.Select({ value: owner, "aria-label": "选择人物", onChange: (e) => setOwner(e.target.value) },
512
+ h("option", { value: "all" }, "请选择"),
513
+ ...persons.filter((p) => p.ownerId !== "self").map((p) => h("option", { key: p.ownerId, value: p.ownerId }, `${p.displayName} · ${p.memoryCount} 条`)))),
514
+ owner !== "all" && owner !== "self" ? h(PersonCard, { key: owner, ownerId: owner, ui, api, notify, onChanged: load }) : Empty("选一个人,查看关系、称呼和印象")) : null,
515
+ section === "recall-test" ? Group({ title: "检索测试" },
516
+ Row({ title: "选择人物" }, ui.Select({ value: owner, "aria-label": "测试哪个人", onChange: (e) => setOwner(e.target.value) },
517
+ h("option", { value: "all" }, "请选择"),
518
+ ...persons.filter((p) => p.ownerId !== "self").map((p) => h("option", { key: p.ownerId, value: p.ownerId }, p.displayName)))),
519
+ owner !== "all" && owner !== "self" ? h(MemoryDebug, { key: `dbg-${owner}`, ownerId: owner, ui, api, notify }) : Empty("选一个人,输入一句话试试会想起什么")) : null,
520
+ section === "memories" ? h(react.Fragment, null,
521
+ Group({ title: "记忆内容", aside: h("span", { className: "fs-group-aside" },
509
522
  data.matchedTotal != null && data.matchedTotal > memories.length ? Muted(`显示 ${memories.length} / ${data.matchedTotal} 条`) : null,
510
523
  Button({ variant: bulk ? "primary" : "ghost", onClick: () => setBulk(!bulk) }, bulk ? "完成" : "批量")) },
524
+ h("div", { className: "fs-pills", role: "group", "aria-label": "按人筛选" }, pill("all", "全部"),
525
+ ...persons.map((p) => pill(p.ownerId, p.ownerId === "self" ? "她自己" : p.displayName, p.ownerId === "self" ? undefined : `${stageLabel(p.relationStage)} · ${p.memoryCount} 条`))),
511
526
  h("div", { className: "fs-row" },
512
527
  Input({ value: q, className: "wide", placeholder: "搜记忆内容(回车)", "aria-label": "搜记忆内容", onChange: (e) => setQ(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") setQApplied(q.trim()); }, onBlur: () => setQApplied(q.trim()) }),
513
528
  ui.Select({ value: source, "aria-label": "来源", onChange: (e) => setSource(e.target.value) }, ...SOURCES.map(([v, l]) => h("option", { key: v, value: v }, l)))),
@@ -518,10 +533,10 @@ window.__ModuleLoader__.load({
518
533
  h("div", { className: "fs-list" }, memories.length === 0 ? Empty("还没有记忆")
519
534
  : 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 })))),
520
535
  pendings.length === 0 ? null : Group({ title: "待办", aside: Tag(String(pendings.length), "outline") },
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) })))),
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 }),
536
+ 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) }))))) : null,
537
+ section === "weekly" ? h(WeeklyPanel, { ui, api, notify, onDone: load }) : null,
538
+ section === "archives" ? h(StoragePanel, { ui, api, notify }) : null,
539
+ section === "backups" ? h(ImportBackup, { ui, api, notify, relTime: props.relTime, onDone: load }) : null,
525
540
  confirmNode);
526
541
  }
527
542
 
@@ -671,7 +686,7 @@ window.__ModuleLoader__.load({
671
686
  return h(react.Fragment, null, groups.map((g) => h(SlangGroupCard, { key: `${g.channelId}-${g.learnedAt ?? 0}-${(g.terms ?? []).length}-${(g.expressions ?? []).length}`, g, api, notify, relTime, ui, onChanged: load })));
672
687
  }
673
688
 
674
- function ActivityPane({ api, apiBase, notify, relTime, ui }) {
689
+ function ActivityPane({ api, apiBase, notify, relTime, ui, section = "activity" }) {
675
690
  const { h, Group, Tag, Empty, Button, Muted } = ui;
676
691
  const [data, load] = usePoll(api, "/activity", 3000);
677
692
  if (!data) return null;
@@ -682,6 +697,7 @@ window.__ModuleLoader__.load({
682
697
  }
683
698
  const decisions = data.decisions ?? [];
684
699
  return h(react.Fragment, null,
700
+ section === "activity" ? h(react.Fragment, null,
685
701
  (data.paused ?? []).length === 0 ? null : Group({ title: "暂停中" },
686
702
  h("div", { className: "fs-list" }, data.paused.map((p) => h("div", { className: "fs-item", key: p.channelId },
687
703
  h("span", { className: "fs-item-main" }, p.title),
@@ -718,9 +734,9 @@ window.__ModuleLoader__.load({
718
734
  return h("details", { className: "fs-step", key: i },
719
735
  h("summary", { className: "fs-step-head" }, ...head, ui.Icon("chevron", 12)),
720
736
  h("div", { className: "fs-step-body" }, h("ul", null, x.outdated.map((o) => h("li", { key: o.id }, `#${o.id}「${o.content}」→ #${o.by}:${o.reason}`)))));
721
- }))),
722
- h(StickerShelf, { api, apiBase, notify, relTime, ui }),
723
- h(SlangShelf, { api, notify, relTime, ui }));
737
+ })))) : null,
738
+ section === "stickers" ? h(StickerShelf, { api, apiBase, notify, relTime, ui }) : null,
739
+ section === "slang" ? h(SlangShelf, { api, notify, relTime, ui }) : null);
724
740
  }
725
741
 
726
742
  return { InspectorPane, ActivityPane };