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 +3 -3
- package/lib/behavior.js +151 -5
- package/lib/client.behavior.js +6 -6
- package/lib/client.js +62 -62
- package/lib/client.models.js +6 -2
- package/lib/client.persona.js +7 -7
- package/lib/client.soul.js +36 -20
- package/lib/db.js +4 -1
- package/lib/douyin-social.js +227 -46
- package/lib/gateway-behavior.js +33 -11
- package/lib/gateway.js +39 -0
- package/lib/routes-v07.js +2 -2
- package/lib/routes.js +39 -4
- package/lib/social.js +6 -6
- package/package.json +5 -4
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.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 栏结构化印象、关系阶段演进、好感与情绪、旧聊天记录导入(断点续传,较早的聊天按时间段总结)、导入麦麦的表情包 / 表达方式 / 黑话、记忆页搜索与批量操作(删除 / 恢复 / 重要度 /
|
|
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
|
|
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
|
-
|
|
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' +
|
package/lib/client.behavior.js
CHANGED
|
@@ -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
|
-
//
|
|
156
|
-
function GroupedTabs({
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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: "习",
|
|
767
|
-
const TABS = [
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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("
|
|
849
|
+
const [tab, setTab] = react.useState("memory");
|
|
850
|
+
const [subtab, setSubtab] = react.useState("retrieval");
|
|
863
851
|
const hasInteracted = react.useRef(false);
|
|
864
|
-
const
|
|
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" ? "
|
|
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 =
|
|
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,
|
|
908
|
+
const timer = setInterval(fn, subtab === "retrieval" ? 2000 : 3000);
|
|
910
909
|
return () => clearInterval(timer);
|
|
911
|
-
}, [
|
|
910
|
+
}, [subtab]);
|
|
912
911
|
// 虚拟时钟走秒(纯展示)
|
|
913
912
|
react.useEffect(() => {
|
|
914
|
-
if (
|
|
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 (
|
|
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:
|
|
939
|
-
:
|
|
940
|
-
:
|
|
941
|
-
:
|
|
942
|
-
:
|
|
943
|
-
:
|
|
944
|
-
:
|
|
945
|
-
:
|
|
946
|
-
:
|
|
947
|
-
:
|
|
948
|
-
:
|
|
949
|
-
:
|
|
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
|
|
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, {
|
|
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: () =>
|
|
961
|
-
h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${
|
|
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
|
|
package/lib/client.models.js
CHANGED
|
@@ -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,
|
|
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 };
|
package/lib/client.persona.js
CHANGED
|
@@ -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 };
|
package/lib/client.soul.js
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
h("
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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 };
|