dsh-fish-soul 0.15.1 → 0.15.3

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.1**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.3**。
6
6
 
7
7
  QQ 交流群:**1125962189**
8
8
 
package/lib/client.js CHANGED
@@ -6,11 +6,11 @@
6
6
  * 宿主契约:
7
7
  * - window.__ModuleLoader__.load({ id, factory }),exports.apply / inject;inject: ["slots"]
8
8
  * - 通过 settings.section 保留设置入口;在宿主支持时通过 main + sidebar.panellist 注册独立页;样式随组件挂载 / 卸载
9
- * - 不 require 任何 Harness Client 包:下面的 Switch / Button / Input / Seg / Tabs / Dot / Tag 照抄
10
- * @deepseek-ai/dsh-client-ui-primitives 的结构、无障碍语义与样式(lib/panel.css,fs- 前缀,只用 --dsw-alias-* 令牌)
9
+ * - 优先使用宿主提供的 @deepseek-ai/dsh-client-ui-primitives;旧宿主缺失时基础控件降级,
10
+ * 个别需要专用语义与布局的控件使用插件局部实现(lib/panel.css,fs- 前缀)
11
11
  *
12
12
  * 设计信条:Avoid unnecessary secondary labels and descriptions. Let the layout do the explaining.
13
- * - DeepSeek Harness 工作台骨架:可折叠侧栏(导航 + 版本)| 居中主任务流 | 按需展开的详情面板;记忆调试是一体化底部输入舱
13
+ * - DeepSeek Harness 工作台骨架:主页与设置页共用两级顶部导航 | 居中主任务流 | 按需展开的详情面板
14
14
  * - 动态 / 整理记录渲染成单行步骤胶囊(<details>),默认收起,点开看原因与想起的记忆
15
15
  * - 平铺紧凑行(标题左 / 控件右),1px 低对比分隔;Row 的 desc 只作悬停提示,meta(实时状态)用等宽小字
16
16
  * - 状态 = 一颗 StateDot + 一个短句;只在出问题时多说一句
@@ -24,6 +24,10 @@ window.__ModuleLoader__.load({
24
24
  var exports = module.exports;
25
25
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
26
26
  const react = require("react");
27
+ // 新版宿主把官方组件作为共享模块提供;旧宿主无此模块时保留原控件。
28
+ /** @type {any} */
29
+ let primitives = null;
30
+ try { primitives = require("@deepseek-ai/dsh-client-ui-primitives"); } catch { /* 旧宿主 */ }
27
31
 
28
32
  const inject = ["slots"];
29
33
  const PLUGIN_ID = "dsh-fish-soul";
@@ -85,7 +89,7 @@ window.__ModuleLoader__.load({
85
89
  return hr < 24 ? `${hr} 小时前` : `${Math.floor(hr / 24)} 天前`;
86
90
  }
87
91
 
88
- // ── 控件:照抄 dsh-ui-primitives ──
92
+ // ── 控件:参考 dsh-ui-primitives,自包含以兼容插件加载器 ──
89
93
  const ICONS = {
90
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",
91
95
  close: "M7 7l10 10M17 7 7 17",
@@ -95,27 +99,42 @@ window.__ModuleLoader__.load({
95
99
  sidebar: "M4 5h16v14H4Z M9 5v14",
96
100
  info: "M12 8h.01 M11 12h1v5h1 M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z"
97
101
  };
98
- const Icon = (name, size = 16) => 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 },
99
- h("path", { d: ICONS[name] }));
102
+ const ICON_COMPONENTS = { close: "IconCloseOutlineRegular", refresh: "IconRefreshOutlineRegular", chevron: "IconChevronDownOutlineRegular", edit: "IconEditOutlineRegular", sidebar: "IconPanelLeftOutlineRegular", info: "IconInfoOutlineRegular" };
103
+ const Icon = (name, size = 16) => primitives?.[ICON_COMPONENTS[name]] ? h(primitives[ICON_COMPONENTS[name]], { size, "aria-hidden": true })
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] }));
100
105
 
101
106
  function Switch({ checked, onChange, label, disabled }) {
107
+ if (primitives?.Switch) return h(primitives.Switch, { checked: checked === true, onChange, label, disabled, className: "fs-native-switch" });
102
108
  return h("button", { type: "button", role: "switch", "aria-checked": checked === true, "aria-label": label, disabled, className: "fs-switch", onClick: () => onChange(!checked) },
103
109
  h("span", { className: "fs-switch-thumb" }));
104
110
  }
105
- const Button = ({ variant, ...props }, ...kids) => h("button", { type: "button", ...props, className: cx("fs-btn", variant) }, ...kids);
106
- const IconButton = (icon, label, onClick, extra) => h("button", { type: "button", className: "fs-icon-btn", "aria-label": label, title: label, onClick, ...extra }, Icon(icon, 14));
111
+ const Button = ({ variant, ...props }, ...kids) => primitives?.Button
112
+ ? h(primitives.Button, { ...props, variant: variant === "danger" ? "outline" : variant ?? "outline", size: "sm", className: cx("fs-native-btn", variant === "danger" && "fs-native-danger", variant === "primary" && "fs-native-primary", props.className) }, ...kids)
113
+ : h("button", { type: "button", ...props, className: cx("fs-btn", variant) }, ...kids);
114
+ const IconButton = (icon, label, onClick, extra) => primitives?.Button
115
+ ? h(primitives.Button, { variant: "ghost", size: "sm", className: "fs-icon-btn fs-native-icon", "aria-label": label, title: label, onClick, ...extra }, Icon(icon, 14))
116
+ : h("button", { type: "button", className: "fs-icon-btn", "aria-label": label, title: label, onClick, ...extra }, Icon(icon, 14));
107
117
 
108
118
  function Seg({ options, value, onChange, label }) {
109
119
  const index = Math.max(0, options.findIndex((o) => o.value === value));
110
120
  return h("div", { className: "fs-seg", role: "radiogroup", "aria-label": label, style: { "--n": options.length, "--i": index } },
111
121
  h("span", { className: "fs-seg-ind", "aria-hidden": true }),
112
- options.map((o) => h("button", {
122
+ options.map((o) => h(primitives?.Pill ?? "button", {
113
123
  type: "button", key: o.value, role: "radio", "aria-checked": o.value === value,
114
124
  className: "fs-seg-btn", onClick: () => { if (o.value !== value) onChange(o.value); }
115
125
  }, o.label)));
116
126
  }
117
127
 
118
- function Tabs({ items, value, onChange }) {
128
+ function Tabs({ items, value, onChange, horizontal = false, hasUpdate = false }) {
129
+ const navRef = react.useRef(null);
130
+ react.useEffect(() => {
131
+ const nav = navRef.current;
132
+ const selected = nav?.querySelector('[role="tab"][aria-selected="true"]');
133
+ if (!nav || !selected) return;
134
+ const outer = nav.getBoundingClientRect(), inner = selected.getBoundingClientRect();
135
+ if (inner.left < outer.left) nav.scrollLeft -= outer.left - inner.left;
136
+ else if (inner.right > outer.right) nav.scrollLeft += inner.right - outer.right;
137
+ }, [value]);
119
138
  const index = Math.max(0, items.findIndex(([v]) => v === value));
120
139
  const onKeyDown = (event, i) => {
121
140
  const n = items.length;
@@ -126,20 +145,60 @@ window.__ModuleLoader__.load({
126
145
  event.currentTarget.parentElement?.querySelectorAll('[role="tab"]').item(next)?.focus();
127
146
  onChange(items[next][0]);
128
147
  };
129
- return h("nav", { className: "fs-nav", role: "tablist", "aria-label": "鱼", "aria-orientation": "vertical", style: { "--n": items.length, "--i": index } },
148
+ return h("nav", { className: "fs-nav", ref: navRef, role: "tablist", "aria-label": "鱼", "aria-orientation": horizontal ? "horizontal" : "vertical", style: { "--n": items.length, "--i": index } },
130
149
  items.map(([v, label], i) => h("button", {
131
150
  type: "button", key: v, role: "tab", id: `fs-tab-${v}`, "aria-selected": v === value, "aria-controls": "fs-tabpanel",
132
- tabIndex: v === value ? 0 : -1, className: "fs-tab", title: label, onClick: () => onChange(v), onKeyDown: (e) => onKeyDown(e, i)
133
- }, h("span", { className: "fs-tab-key", "aria-hidden": true }, TAB_KEYS[v] ?? label.slice(0, 1)), h("span", { className: "fs-tab-label" }, label))));
151
+ tabIndex: v === value ? 0 : -1, className: "fs-tab", title: v === "version" && hasUpdate ? "版本更新(有新版本)" : label, "aria-label": v === "version" && hasUpdate ? "版本更新(有新版本)" : label, onClick: () => onChange(v), onKeyDown: (e) => onKeyDown(e, i)
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
+ }
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 })));
134
182
  }
135
183
 
136
184
  /** StateDot:done / warning / error / idle 是圆点,ongoing 是转圈。 */
137
- const Dot = (state) => state === "ongoing"
185
+ const Dot = (state) => primitives?.StateDot ? h(primitives.StateDot, { state }) : state === "ongoing"
138
186
  ? h("svg", { className: "fs-spin", width: 14, height: 14, viewBox: "0 0 24 24", "aria-hidden": true },
139
187
  h("g", null, h("circle", { cx: 12, cy: 12, r: 9.5 }), h("circle", { cx: 12, cy: 12, r: 9.5 })))
140
188
  : h("span", { className: "fs-dot", "data-state": state, "aria-hidden": true });
141
189
  const State = (state, text, title) => h("span", { className: "fs-state", "data-state": state, title: title ?? text, role: "status" }, Dot(state), h("span", null, text));
142
- const Tag = (text, tone, extra, ...kids) => h("span", { className: "fs-tag", "data-tone": tone, ...extra }, text, ...kids);
190
+ const Tag = (text, tone, extra, ...kids) => {
191
+ if (!primitives?.Tag) return h("span", { className: "fs-tag", "data-tone": tone, ...extra }, text, ...kids);
192
+ const tag = h(primitives.Tag, { tone: tone ?? "neutral", className: "fs-native-tag" }, text, ...kids);
193
+ return extra ? h("span", { className: "fs-native-tag-wrap", ...extra }, tag) : tag;
194
+ };
195
+ const Pill = ({ active, children, ...props }) => primitives?.Pill
196
+ ? h(primitives.Pill, { ...props, active }, children)
197
+ : h("button", { type: "button", ...props }, children);
198
+ function Check({ checked, onChange, label, hideLabel, title }) {
199
+ return primitives?.Checkbox ? h(primitives.Checkbox, { checked, onChange, label, title, className: cx("fs-native-check", hideLabel && "label-hidden") })
200
+ : h("label", { className: cx("fs-native-check", hideLabel && "label-hidden"), title }, h("input", { type: "checkbox", checked, onChange: (e) => onChange(e.target.checked) }), h("span", null, label));
201
+ }
143
202
  const Muted = (text, extra) => h("span", { className: "fs-unit", ...extra }, text);
144
203
 
145
204
  // 行:标题左 / 控件右。desc(解释性文字)不占版面,只作悬停提示;meta(实时状态)以等宽小字跟在标题下
@@ -152,19 +211,23 @@ window.__ModuleLoader__.load({
152
211
  meta ? h("span", { className: "fs-row-meta" }, meta) : null),
153
212
  h("div", { className: "fs-ctl" }, ...kids));
154
213
  }
155
- /** @param {{ title?: any, aside?: any, tip?: string }} props @param {...any} kids */
156
- function Group({ title, aside, tip }, ...kids) {
157
- return h("section", { className: "fs-group" },
214
+ /** @param {{ title?: any, aside?: any, tip?: string, className?: string }} props @param {...any} kids */
215
+ function Group({ title, aside, tip, className }, ...kids) {
216
+ return h("section", { className: cx("fs-group", className) },
158
217
  title ? h("h3", { className: "fs-group-head", title: tip }, h("span", null, title), aside ?? null) : null, ...kids);
159
218
  }
160
- const Input = (props) => h("input", { className: "fs-input", spellCheck: false, autoComplete: "off", ...props, className: cx("fs-input", props.className) });
161
- const Num = (props) => h("input", { type: "number", inputMode: "numeric", ...props, className: "fs-input num" });
219
+ const Input = (props) => {
220
+ if (!primitives?.Input) return h("input", { spellCheck: false, autoComplete: "off", ...props, className: cx("fs-input", props.className) });
221
+ const { className, style, ...input } = props;
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
+ };
224
+ const Num = (props) => Input({ type: "number", inputMode: "numeric", ...props, className: cx("num", props.className) });
162
225
  const Area = (props) => h("textarea", { rows: 2, spellCheck: false, ...props, className: "fs-input wide" });
163
226
 
164
227
  function Secret({ value, onChange, onBlur, placeholder, label }) {
165
228
  const [shown, setShown] = react.useState(false);
166
229
  return h("div", { className: "fs-affix" },
167
- h("input", { className: "fs-input", type: shown ? "text" : "password", value, placeholder, onChange, onBlur, autoComplete: "off", spellCheck: false, "aria-label": label }),
230
+ Input({ type: shown ? "text" : "password", value, placeholder, onChange, onBlur, autoComplete: "off", spellCheck: false, "aria-label": label }),
168
231
  IconButton("eye", shown ? "隐藏" : "显示", () => setShown(!shown)));
169
232
  }
170
233
  const Select = (props, ...options) => h("div", { className: "fs-affix select" },
@@ -183,7 +246,7 @@ window.__ModuleLoader__.load({
183
246
  }
184
247
  const split = (v, re = /[,,\s]+/) => String(v).split(re).map((x) => x.trim()).filter(Boolean);
185
248
 
186
- const ui = { h, cx, Icon, Switch, Button, IconButton, Seg, Dot, State, Tag, Muted, Row, Group, Input, Num, Area, Secret, Select, Empty, Item, useForm, split };
249
+ const ui = { h, cx, Icon, Switch, Button, IconButton, Seg, Dot, State, Tag, Pill, Check, Muted, Row, Group, Input, Num, Area, Secret, Select, Empty, Item, useForm, split };
187
250
 
188
251
  // ── 记忆检索 ──
189
252
  function RetrievalPane({ data, refresh, notify }) {
@@ -328,18 +391,18 @@ window.__ModuleLoader__.load({
328
391
  onChange: (e) => patch({ [name]: e.target.value === "" ? "" : Number(e.target.value) }), onBlur: () => save({ [name]: form[name] === "" ? fallback : form[name] }) });
329
392
 
330
393
  return h(react.Fragment, null,
331
- h("section", { className: "fs-group" },
394
+ h("section", { className: "fs-life-overview" },
332
395
  h("div", { className: "fs-now" },
333
- Dot(sleeping ? "idle" : "done"),
334
- h("span", { className: "fs-now-title" }, slot.activity ?? "未排期"),
335
- today.dailyMoodTone ? Tag(today.dailyMoodTone) : null,
396
+ h("div", { className: "fs-now-activity" }, Dot(sleeping ? "idle" : "done"),
397
+ h("span", { className: "fs-now-title" }, slot.activity ?? "未排期")),
336
398
  h("span", { className: "fs-now-meta" },
399
+ today.dailyMoodTone ? Tag(today.dailyMoodTone) : null,
337
400
  clock.isAccelerated || scale > 1 ? Tag(`${scale}×`, "warning") : null,
338
401
  h("span", null, fmtClock(clock.virtualNowMs)))),
339
402
  schedule.length > 0 ? h("div", { className: "fs-schedule" },
340
403
  schedule.map((s, i) => h("div", { className: "fs-slot", key: i, "aria-current": s.range === slot.range },
341
404
  h("span", null, s.range), h("span", { title: s.activity }, s.activity)))) : null),
342
- Group({},
405
+ Group({ className: "fs-life-settings" },
343
406
  Row({ title: "主动开口" }, h(Switch, { label: "主动开口", checked: form.enabled, onChange: (v) => save({ enabled: v }) })),
344
407
  form.enabled ? Row({ title: "每天最多" }, Tag(`今天 ${today.proactiveCount ?? 0}`), num("dailyMaxCount", "每天最多", 0, 50, 10), Muted("次")) : null,
345
408
  form.enabled ? Row({ title: "时段" }, num("activeStart", "开始", 0, 23, 8), Muted("–"), num("activeEnd", "结束", 1, 24, 24), Muted("点")) : null,
@@ -347,7 +410,7 @@ window.__ModuleLoader__.load({
347
410
  form.enabled ? Row({ title: "主人私聊" }, Input({ value: form.masterPrivateChannelId, placeholder: "private:主人 QQ", onChange: (e) => patch({ masterPrivateChannelId: e.target.value }), onBlur: () => save() })) : null,
348
411
  form.enabled ? Row({ title: "群聊", stack: true },
349
412
  ...form.selfGroupChannelIds.map((cid) => Tag(cid, undefined, { key: cid },
350
- h("button", { type: "button", "aria-label": `移除 ${cid}`, onClick: () => save({ selfGroupChannelIds: form.selfGroupChannelIds.filter((x) => x !== cid) }) }, Icon("close", 12)))),
413
+ IconButton("close", `移除 ${cid}`, () => save({ selfGroupChannelIds: form.selfGroupChannelIds.filter((x) => x !== cid) })))),
351
414
  Input({ value: newGroup, placeholder: "group:群号", style: { flex: "0 1 160px", width: "160px" }, onChange: (e) => setNewGroup(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") addGroup(); } }),
352
415
  Button({ variant: "ghost", disabled: newGroup.trim() === "", onClick: addGroup }, "添加")) : null));
353
416
  }
@@ -360,7 +423,8 @@ window.__ModuleLoader__.load({
360
423
  const [form, setForm, patch] = useForm(data, () => ({
361
424
  enabled: qq.enabled === true, wsUrl: qq.wsUrl ?? "ws://127.0.0.1:3001", accessToken: "", masterQqUin: data.masterQqUin ?? "",
362
425
  groupAllowList: (qq.groupAllowList ?? []).join(", "), allGroups: qq.allGroups === true, followUpCount: qq.followUpCount ?? 3, followUpSeconds: qq.followUpSeconds ?? 120, batchMs: qq.batchMs ?? 3000,
363
- callNames: (qq.callNames ?? []).join(", "), ij: { ...(qq.interject ?? {}), chance: Math.round((qq.interject?.chance ?? 0.05) * 100), keywords: (qq.interject?.keywords ?? []).join(", ") }
426
+ callNames: (qq.callNames ?? []).join(", "), ij: { ...(qq.interject ?? {}), chance: Math.round((qq.interject?.chance ?? 0.05) * 100), keywords: (qq.interject?.keywords ?? []).join(", ") },
427
+ stickersPerDay: qq.stickers?.perDay ?? 10
364
428
  }));
365
429
  if (!data || !form) return null;
366
430
 
@@ -410,7 +474,10 @@ window.__ModuleLoader__.load({
410
474
  form.enabled && ij.enabled ? Row({ title: "插话概率" }, ijNum("chance", "插话概率", 0, 100), Muted("%"), ijNum("maxPerHour", "每小时最多", 0, 30), Muted("次/小时")) : null,
411
475
  form.enabled && ij.enabled ? Row({ title: "说完话后冷却" }, ijNum("cooldownMinutes", "冷却", 0, 1440), Muted("分钟")) : null,
412
476
  form.enabled && ij.enabled ? Row({ title: "感兴趣的话题" }, Input({ value: ij.keywords ?? "", placeholder: "猫, 原神, 考研", onChange: (e) => patch({ ij: { ...ij, keywords: e.target.value } }), onBlur: () => save() })) : null,
413
- form.enabled ? Row({ title: "收藏表情包", desc: qq.stickers?.collect !== false ? undefined : "看到喜欢的表情会存下来,之后自己用" }, h(Switch, { label: "收藏表情包", checked: qq.stickers?.collect !== false, onChange: (v) => run("/stickers/config", { collect: v }, v ? "会收藏喜欢的表情了" : "不再收藏表情", "保存失败") })) : null,
477
+ form.enabled ? Row({ title: "收藏表情包", desc: qq.stickers?.collect !== false ? undefined : "看到喜欢的表情会存下来,之后自己用" },
478
+ h(Switch, { label: "收藏表情包", checked: qq.stickers?.collect !== false, onChange: (v) => run("/stickers/config", { collect: v }, v ? "会收藏喜欢的表情了" : "不再收藏表情", "保存失败") }),
479
+ Num({ min: 0, max: 100, value: form.stickersPerDay, "aria-label": "每天收藏几张", onChange: (e) => patch({ stickersPerDay: e.target.value }), onBlur: () => { if (form.stickersPerDay !== "") run("/stickers/config", { perDay: form.stickersPerDay }, "每天的上限改了", "保存失败"); } }),
480
+ Muted("张/天")) : null,
414
481
  form.enabled ? Row({ title: "学群里的说法", desc: qq.slang?.enabled !== false ? undefined : "学每个群的梗和说话风格" }, h(Switch, { label: "学群里的说法", checked: qq.slang?.enabled !== false, onChange: (v) => run("/slang/config", { enabled: v }, v ? "会学群里的说法了" : "不再学群里的说法", "保存失败") })) : null,
415
482
  form.enabled && qq.slang?.enabled !== false ? Row({ title: "学说话方式", desc: "从别人的话里学「什么场合怎么说」,只学别人的、不带人名" }, h(Switch, { label: "学说话方式", checked: qq.slang?.expressions !== false, onChange: (v) => run("/slang/config", { expressions: v }, v ? "会学说话方式了" : "不再学说话方式", "保存失败") })) : null,
416
483
  form.enabled && qq.slang?.enabled !== false ? Row({ title: "私聊也学", desc: "好友私聊里学到的只在那个私聊里用" }, h(Switch, { label: "私聊也学", checked: qq.slang?.privateChats !== false, onChange: (v) => run("/slang/config", { privateChats: v }, v ? "私聊里也会学了" : "只学群里的", "保存失败") })) : null),
@@ -618,34 +685,51 @@ window.__ModuleLoader__.load({
618
685
  }
619
686
 
620
687
  // ── 面板根组件:页签 + 数据装载 + 轮询 ──
621
- const TAB_KEYS = { retrieval: "索", persona: "人", life: "自", inspector: "忆", activity: "动", behavior: "习", models: "模", qq: "Q", bili: "B", douyin: "抖" };
688
+ const TAB_KEYS = { guide: "引", retrieval: "索", persona: "人", life: "自", inspector: "忆", activity: "动", behavior: "习", models: "模", qq: "Q", bili: "B", douyin: "抖", version: "版" };
622
689
  const TABS = [["retrieval", "记忆检索"], ["persona", "人格"], ["life", "自走"], ["inspector", "记忆检视"], ["activity", "动态"], ["behavior", "习惯"], ["models", "模型"], ["qq", "QQ"], ["bili", "B站"], ["douyin", "抖音"]];
623
-
624
- // ── 首次引导:没点「不再显示」就在页签上面列一张清单 ──
625
- function Onboarding({ onGo, notify }) {
690
+ const NAV_GROUPS = [
691
+ { id: "start", label: "开始", ids: ["guide"] },
692
+ { id: "memory", label: "记忆", ids: ["retrieval", "inspector"] },
693
+ { id: "character", label: "角色", ids: ["persona", "behavior"] },
694
+ { id: "daily", label: "日常", ids: ["life", "activity"] },
695
+ { id: "channels", label: "平台", ids: ["qq", "bili", "douyin"] },
696
+ { id: "system", label: "系统", ids: ["models", "interface", "version"] }
697
+ ];
698
+
699
+ // 服务端仍提供原始步骤与状态;短标题只调整展示,未知步骤保留服务端原文。
700
+ const ONBOARDING_LABELS = { persona: "选择人格", model: "配置模型", qq: "连接 QQ", master: "设置主人 QQ", retrieval: "配置向量检索", import: "导入聊天记录", voice: "配置语音" };
701
+ // 独立引导页:关闭自动显示后仍可从「开始」重新打开。
702
+ function Onboarding({ onGo, onDismiss, onHide, notify }) {
626
703
  const [data, setData] = react.useState(null);
627
- const load = react.useCallback(async () => { const r = await api("GET", "/onboarding/state"); if (r?.ok) setData(r); }, []);
704
+ const [error, setError] = react.useState(null);
705
+ const load = react.useCallback(async () => { const r = await api("GET", "/onboarding/state"); if (r?.ok) { setData(r); setError(null); } else setError(r?.error ?? "引导状态暂不可用"); }, []);
628
706
  react.useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
629
- if (!data || data.dismissed) return null;
707
+ if (!data) return error ? h("div", { className: "fs-notice", role: "alert" }, h("b", null, "引导加载失败"), h("span", null, error), Button({ variant: "secondary", onClick: load }, "重试")) : h("div", { className: "fs-empty" }, Dot("ongoing"));
630
708
  async function dismiss() {
631
709
  const r = await api("POST", "/onboarding/dismiss", { dismissed: true });
632
- if (r?.ok) setData({ ...data, dismissed: true }); else notify(r?.error ?? "操作失败");
710
+ if (r?.ok) { setData({ ...data, dismissed: true }); onDismiss(); } else notify(r?.error ?? "操作失败");
633
711
  }
634
- const left = data.steps.filter((x) => !x.done && !x.optional).length;
635
- return Group({ title: left === 0 ? "就绪" : `开始 · ${data.steps.length - left}/${data.steps.length}`, aside: IconButton("close", "不再显示", dismiss) },
636
- h("div", { className: "fs-list" }, data.steps.map((x) => h("div", { className: "fs-item", key: x.id },
637
- h("span", { className: "fs-dot", "data-state": x.done ? "done" : x.optional ? "idle" : "warning", "aria-hidden": true }),
638
- h("span", { className: cx("fs-item-main", x.done && "fs-muted") }, x.title),
639
- h("span", { className: "fs-item-meta" },
640
- x.optional && !x.done ? Tag("可选", "outline") : null,
641
- x.done ? null : x.tab ? Button({ variant: "ghost", onClick: () => onGo(x.tab) }, "去设置") : null)))));
712
+ const steps = data.steps ?? [];
713
+ const completed = steps.filter((x) => x.done).length;
714
+ const left = steps.filter((x) => !x.done && !x.optional).length;
715
+ return Group({ className: "fs-onboarding", title: `${left === 0 ? "基础就绪" : "开始"} · ${completed}/${steps.length}`, aside: h("span", { className: "fs-group-aside" }, Button({ variant: "ghost", onClick: onHide }, "隐藏开始"), data.dismissed ? null : IconButton("close", "以后不再自动显示", dismiss)) },
716
+ h("div", { className: "fs-list" }, steps.map((x) => {
717
+ const label = ONBOARDING_LABELS[x.id] ?? x.title;
718
+ return h("div", { className: "fs-item", key: x.id },
719
+ h("span", { className: "fs-onboarding-dot", role: "img", "aria-label": x.done ? "已完成" : x.optional ? "可选,未完成" : "未完成" }, Dot(x.done ? "done" : x.optional ? "idle" : "warning")),
720
+ h("span", { className: cx("fs-item-main", x.done && "fs-muted") }, label),
721
+ h("span", { className: "fs-item-meta" },
722
+ x.optional && !x.done ? Tag("可选", "outline") : null,
723
+ x.done ? null : x.tab ? Button({ variant: "ghost", "aria-label": `设置${label}`, onClick: () => onGo(x.tab) }, "去设置") : null));
724
+ })));
642
725
  }
643
726
  // 版本与更新:显示当前版本,自动 / 手动去 npm(不通再 GitHub)看有没有新版;只提示,不替你装
644
- function UpdateBar({ notify, compact, onOpen }) {
727
+ function UpdateBar({ notify, onUpdateState }) {
645
728
  const [data, setData] = react.useState(null);
646
729
  const [busy, setBusy] = react.useState(false);
647
730
  const load = react.useCallback(async () => { const r = await api("GET", "/update/state"); if (r?.ok) setData(r); }, []);
648
731
  react.useEffect(() => { load(); const t = setInterval(load, 60000); return () => clearInterval(t); }, [load]);
732
+ react.useEffect(() => { if (data && onUpdateState) onUpdateState(data.hasUpdate === true); }, [data, onUpdateState]);
649
733
  if (!data) return null;
650
734
  async function check() {
651
735
  setBusy(true);
@@ -661,9 +745,6 @@ window.__ModuleLoader__.load({
661
745
  async function copy() {
662
746
  try { await navigator.clipboard.writeText(data.command); notify("已复制更新命令"); } catch { notify("复制失败,手动选中复制"); }
663
747
  }
664
- if (compact) return h("button", { type: "button", className: "fs-ver", onClick: onOpen, title: data.hasUpdate ? `v${data.latest}` : undefined, "aria-label": "版本" },
665
- h("span", { className: "fs-dot", "data-state": data.hasUpdate ? "warning" : data.latest ? "done" : "idle", "aria-hidden": true }),
666
- h("span", { className: "fs-tab-label num" }, `v${data.current}`));
667
748
  const when = data.checkedAt ? new Date(data.checkedAt).toLocaleString("zh-CN", { hour12: false }) : "";
668
749
  const status = data.hasUpdate ? Tag(`有新版本 v${data.latest}`, "warning")
669
750
  : data.latest ? Tag("已是最新", "success")
@@ -694,16 +775,33 @@ window.__ModuleLoader__.load({
694
775
  }
695
776
  }
696
777
 
697
- function FishSoulSection() {
778
+ function FishSoulSection({ standalone = false } = {}) {
698
779
  const css = useCss();
780
+ // 界面偏好只保存在当前宿主浏览器,不修改服务端引导进度。
781
+ const guideVisibilityKey = "fish-soul:guide-visible";
782
+ const readGuideVisible = () => { try { return window.localStorage.getItem(guideVisibilityKey) !== "false"; } catch { return true; } };
783
+ const [guideVisible, setGuideVisible] = react.useState(readGuideVisible);
699
784
  const [tab, setTab] = react.useState("retrieval");
785
+ const hasInteracted = react.useRef(false);
786
+ const goTab = (next) => { hasInteracted.current = true; setTab(next); };
700
787
  const [retrieval, setRetrieval] = react.useState(null);
701
788
  const [life, setLife] = react.useState(null);
702
789
  const [channels, setChannels] = react.useState(null);
703
790
  const [toast, setToast] = react.useState(null);
704
- const [navOpen, setNavOpen] = react.useState(true);
705
- const [detail, setDetail] = react.useState(null);
791
+ const [updateAvailable, setUpdateAvailable] = react.useState(false);
706
792
  const notify = react.useCallback((text) => setToast({ key: Date.now(), text }), []);
793
+ const setGuideVisibility = (visible) => {
794
+ try { window.localStorage.setItem(guideVisibilityKey, String(visible)); }
795
+ catch { notify("无法保存界面设置"); return; }
796
+ setGuideVisible(visible);
797
+ if (!visible) setTab((current) => current === "guide" ? "retrieval" : current);
798
+ window.dispatchEvent(new Event("fish-soul:guide-visibility"));
799
+ };
800
+ react.useEffect(() => {
801
+ const sync = () => { const visible = readGuideVisible(); setGuideVisible(visible); if (!visible) setTab((current) => current === "guide" ? "retrieval" : current); };
802
+ window.addEventListener("fish-soul:guide-visibility", sync); window.addEventListener("storage", sync);
803
+ return () => { window.removeEventListener("fish-soul:guide-visibility", sync); window.removeEventListener("storage", sync); };
804
+ }, []);
707
805
 
708
806
  react.useEffect(() => {
709
807
  if (toast === null) return undefined;
@@ -717,6 +815,14 @@ window.__ModuleLoader__.load({
717
815
  const loadChannels = react.useCallback(load("/channels/state", setChannels), []);
718
816
 
719
817
  react.useEffect(() => { loadRetrieval(); loadLife(); loadChannels(); }, []);
818
+ // 仅在初次加载且用户尚未操作时自动进入引导,慢请求不能打断用户。
819
+ react.useEffect(() => {
820
+ let active = true;
821
+ api("GET", "/onboarding/state").then((r) => {
822
+ if (active && !hasInteracted.current && readGuideVisible() && r?.ok && r.dismissed === false && Array.isArray(r.steps) && r.steps.some((step) => !step.done)) setTab("guide");
823
+ });
824
+ return () => { active = false; };
825
+ }, []);
720
826
  // 检索页 2s 刷新(重建进度);QQ / B站 / 抖音 3s 刷新
721
827
  react.useEffect(() => {
722
828
  const fn = tab === "retrieval" ? loadRetrieval : tab === "qq" || tab === "bili" || tab === "douyin" ? loadChannels : null;
@@ -732,8 +838,21 @@ window.__ModuleLoader__.load({
732
838
  return () => clearInterval(timer);
733
839
  }, [tab]);
734
840
 
841
+ // 导航红点使用同一只读状态接口;版本页自身检查后的结果即时同步。
842
+ react.useEffect(() => {
843
+ if (tab === "version") return undefined;
844
+ let active = true;
845
+ const loadUpdate = async () => { const r = await api("GET", "/update/state"); if (active && r?.ok) setUpdateAvailable(r.hasUpdate === true); };
846
+ loadUpdate();
847
+ const timer = setInterval(loadUpdate, 60000);
848
+ return () => { active = false; clearInterval(timer); };
849
+ }, [tab]);
850
+
735
851
  if (css === null) return null;
736
- const pane = tab === "retrieval" ? h(RetrievalPane, { data: retrieval, refresh: loadRetrieval, notify })
852
+ const pane = tab === "guide" ? h(Onboarding, { onGo: goTab, onDismiss: () => goTab("retrieval"), onHide: () => setGuideVisibility(false), notify })
853
+ : tab === "interface" ? Row({ title: "显示开始" }, h(Switch, { label: "显示开始", checked: guideVisible, onChange: setGuideVisibility }))
854
+ : tab === "version" ? h(Boundary, { name: "版本" }, h(UpdateBar, { notify, onUpdateState: setUpdateAvailable }))
855
+ : tab === "retrieval" ? h(RetrievalPane, { data: retrieval, refresh: loadRetrieval, notify })
737
856
  : tab === "life" ? h(LifePane, { data: life, refresh: loadLife, notify })
738
857
  : tab === "inspector" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.InspectorPane, failText: "记忆检视加载失败", notify })
739
858
  : tab === "activity" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.ActivityPane, failText: "动态加载失败", notify })
@@ -744,30 +863,23 @@ window.__ModuleLoader__.load({
744
863
  : tab === "bili" ? h(BiliPane, { data: channels, refresh: loadChannels, notify })
745
864
  : h(ChunkTab, { file: "client.douyin.js", pick: (m) => m.DouyinPane, failText: "抖音页加载失败", data: channels, refresh: loadChannels, notify });
746
865
 
747
- // 工作台骨架:可折叠侧栏(导航 + 版本)| 主任务流(引导 + 当前页)| 按需展开的详情面板(版本与更新)
748
- const label = TABS.find(([v]) => v === tab)?.[1] ?? "";
749
- return h("div", { className: "fs-host" }, h("div", { className: cx("fs", navOpen ? null : "nav-collapsed", detail ? "has-detail" : null) },
866
+ // 工作台:主页与设置页都把版本更新作为系统分组下的独立页签。
867
+ const tabs = [...(guideVisible ? [["guide", "使用引导"]] : []), ...TABS, ["interface", "界面"], ["version", "版本更新"]];
868
+ const label = tabs.find(([v]) => v === tab)?.[1] ?? "";
869
+ return h("div", { className: "fs-host" }, h("div", { className: cx("fs", standalone ? "fs-standalone" : "fs-settings"), onPointerDown: () => { hasInteracted.current = true; }, onKeyDown: () => { hasInteracted.current = true; } },
750
870
  css === "" ? null : h("style", { "data-plugin-css": `${PLUGIN_ID}/panel.css` }, css),
751
- h("aside", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
752
- h("div", { className: "fs-side-head" },
753
- h("span", { className: "fs-tab-label fs-brand" }, "鱼"),
754
- IconButton("sidebar", navOpen ? "收起侧栏" : "展开侧栏", () => setNavOpen(!navOpen), { "aria-expanded": navOpen })),
755
- h(Tabs, { items: TABS, value: tab, onChange: setTab }),
756
- h("div", { className: "fs-side-foot" }, h(Boundary, { name: "版本" }, h(UpdateBar, { notify, compact: true, onOpen: () => setDetail(detail === "version" ? null : "version") }))))),
871
+ h("div", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
872
+ h(GroupedTabs, { items: tabs, value: tab, onChange: goTab, hasUpdate: updateAvailable }))),
757
873
  h("main", { className: "fs-main" },
758
874
  h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label)),
759
- h(Boundary, { name: "引导" }, h(Onboarding, { onGo: setTab, notify })),
760
875
  h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${tab}`, key: tab }, h(Boundary, { name: "这一页" }, pane))),
761
- detail === "version" ? h("aside", { className: "fs-detail", "aria-label": "版本" }, h("div", { className: "fs-detail-inner" },
762
- h("div", { className: "fs-detail-head" }, h("span", null, "版本"), IconButton("close", "关闭", () => setDetail(null))),
763
- h(Boundary, { name: "版本" }, h(UpdateBar, { notify })))) : null,
764
876
  toast === null ? null : h("div", { className: "fs-toast", role: "status", key: toast.key }, toast.text)));
765
877
  }
766
878
 
767
879
  // 同一组件可在设置弹窗或宿主主面板内渲染;只改变承载容器。
768
880
  function FishSoulPage() {
769
881
  return h("div", { className: "fs-page-shell" },
770
- h("div", { className: "fs-page-scroll" }, h(FishSoulSection)));
882
+ h("div", { className: "fs-page-scroll" }, h(FishSoulSection, { standalone: true })));
771
883
  }
772
884
 
773
885
  function FishSoulPanelIcon({ size = 16 } = {}) {
@@ -44,7 +44,7 @@ window.__ModuleLoader__.load({
44
44
  }
45
45
  return opts;
46
46
  };
47
- return Group({ title: "按功能选模型", aside: host ? Muted(`DSH 默认:${providerName(host.provider)} · ${host.model}`) : null },
47
+ return Group({ title: "按功能选模型", className: "fs-model-routes", aside: host ? Muted(`DSH 默认:${providerName(host.provider)} · ${host.model}`) : null },
48
48
  providers.length === 0 && !host ? Row({ title: "可用模型" }, State("warning", "DSH 里还没配模型")) : null,
49
49
  data.lastError ? Row({ title: "上次调用" }, State("error", data.lastError)) : null,
50
50
  ...(data.features ?? []).map((f) => Row({ title: f.label, key: f.id, meta: f.resolved ? (f.selection ? undefined : `现在用 ${f.resolved.model}`) : "没有可用的模型" },
@@ -61,7 +61,7 @@ window.__ModuleLoader__.load({
61
61
  h("span", { className: "fs-item-sub" }, origin));
62
62
  }
63
63
  return h("div", { className: "fs-item" },
64
- selectable ? h("input", { type: "checkbox", checked: selected === true, "aria-label": `选中 #${m.id}`, onChange: (e) => onSelect(m.id, e.target.checked) }) : null,
64
+ selectable ? h(ui.Check, { checked: selected === true, label: `选中 #${m.id}`, hideLabel: true, onChange: (v) => onSelect(m.id, v) }) : null,
65
65
  Tag(KIND[m.kind] ?? m.kind),
66
66
  h("span", { className: "fs-item-main", title: `${m.content}\n${origin}` }, m.content),
67
67
  h("span", { className: "fs-item-meta" },
@@ -142,7 +142,7 @@ window.__ModuleLoader__.load({
142
142
  h("div", { className: "fs-list" }, data.outdated.map((m) => Item({
143
143
  key: m.id, lead: Tag(m.status === "outdated" ? "过时" : m.status === "superseded" ? "改掉" : "删了", "outline"),
144
144
  text: m.content, title: `${m.content}\n${m.evidence || m.source}`,
145
- meta: [h("button", { key: "r", type: "button", className: "fs-btn ghost", onClick: () => restore(m.id) }, "恢复")]
145
+ meta: [Button({ key: "r", variant: "ghost", onClick: () => restore(m.id) }, "恢复")]
146
146
  })))));
147
147
  }
148
148
 
@@ -267,8 +267,7 @@ window.__ModuleLoader__.load({
267
267
  Row({ title: `${FORMAT[pv.format] ?? pv.format} · ${pv.chatCount} 个聊天 · ${pv.messageCount} 条消息${pv.memoryCount ? ` · 现成记忆 ${pv.memoryCount} 条` : ""}` },
268
268
  ...(pv.warnings ?? []).map((w, i) => Tag(w, "warning", { key: i }))),
269
269
  pv.speakers.length > 0 ? h("div", { className: "fs-list" }, pv.speakers.slice(0, 40).map((s) => h("div", { className: "fs-item", key: s.key },
270
- h("label", { className: "fs-check", title: "勾上 = 这是她自己说的话" },
271
- h("input", { type: "checkbox", checked: opts.self[s.key] === true, onChange: (e) => setOpts({ ...opts, self: { ...opts.self, [s.key]: e.target.checked } }) }), "是她"),
270
+ h(ui.Check, { checked: opts.self[s.key] === true, label: "是她", title: "勾上 = 这是她自己说的话", onChange: (v) => setOpts({ ...opts, self: { ...opts.self, [s.key]: v } }) }),
272
271
  h("span", { className: "fs-item-main", title: s.chats.join("、") }, s.name),
273
272
  h("span", { className: "fs-item-meta" },
274
273
  s.existing ? Tag(`已认识 · ${stageLabel(s.existing.relationStage)}`, "success") : null,
@@ -360,7 +359,7 @@ window.__ModuleLoader__.load({
360
359
  const persons = data.persons ?? [];
361
360
  const memories = data.memories ?? [];
362
361
  const pendings = data.pendings ?? [];
363
- const pill = (ownerId, label, title) => h("button", { type: "button", key: ownerId, className: "fs-pill", "aria-pressed": owner === ownerId, title, onClick: () => setOwner(ownerId) }, label);
362
+ const pill = (ownerId, label, title) => ui.Pill({ key: ownerId, active: owner === ownerId, className: "fs-pill", "aria-pressed": owner === ownerId, title, onClick: () => setOwner(ownerId), children: label });
364
363
  const onSelect = (id, on) => setSel((prev) => { const next = new Set(prev); if (on) next.add(id); else next.delete(id); return next; });
365
364
  const filter = { ownerId: owner, q: qApplied, source };
366
365
  const narrowed = owner !== "all" || qApplied !== "" || source !== "";
@@ -424,23 +423,84 @@ window.__ModuleLoader__.load({
424
423
  recalled: ["撤回", "outline"], command: ["命令", "info"], proactive: ["主动", "info"], digest: ["记下", "success"],
425
424
  filtered: ["改写", "outline"], diary: ["日记", "success"], browse: ["刷视频", "outline"], share: ["分享", "success"], post: ["发动态", "success"], comment: ["评论区", "info"], mood: ["心情", "info"], poke: ["戳回", "info"], react: ["回应", "info"]
426
425
  };
427
- /** 她收藏的表情包(动态页底部) */
426
+ /** 读本地图片 → base64(去掉 data: 前缀);读不出来给 null。 */
427
+ function readBase64(file) {
428
+ return new Promise((resolve) => {
429
+ const reader = new FileReader();
430
+ reader.onload = () => { const s = String(reader.result ?? ""); resolve(s.includes(",") ? s.slice(s.indexOf(",") + 1) : null); };
431
+ reader.onerror = () => resolve(null);
432
+ reader.readAsDataURL(file);
433
+ });
434
+ }
435
+
436
+ /** 一张表情:名字能直接改(失焦 / 回车保存,Esc 撤销),也能删 */
437
+ function StickerCard({ x, api, apiBase, notify, refresh, relTime, ui, onRemove }) {
438
+ const { h, Input, IconButton } = ui;
439
+ const base = x.file.replace(/\.[a-z]+$/i, "");
440
+ const [name, setName] = react.useState(base);
441
+ const [busy, setBusy] = react.useState(false);
442
+ const saving = react.useRef(false);
443
+ const done = react.useRef(false);
444
+ async function save() {
445
+ if (saving.current || done.current) return;
446
+ const next = name.trim();
447
+ if (next === "" || next === base) { setName(base); return; }
448
+ saving.current = true; setBusy(true);
449
+ const r = await api("POST", "/stickers/rename", { file: x.file, workspacePath: x.workspacePath, name: next });
450
+ saving.current = false; setBusy(false);
451
+ if (r?.ok) { done.current = true; notify("改好了"); refresh(); }
452
+ else { notify(r?.error ?? "改名失败"); setName(base); }
453
+ }
454
+ const tip = [x.file, x.tracked ? (x.from === "主人" ? "主人导入的" : x.from ? `从 ${x.from} 那里` : "") : "主人自己放进目录的", x.summary, relTime(x.at, Date.now())].filter(Boolean).join("\n");
455
+ return h("figure", { className: "fs-sticker", title: tip },
456
+ h("img", { alt: base, loading: "lazy", src: `${apiBase}/stickers/file?file=${encodeURIComponent(x.file)}&ws=${encodeURIComponent(x.workspacePath)}` }),
457
+ h("figcaption", null,
458
+ Input({ value: name, "aria-label": `给 ${x.file} 改名`, disabled: busy, onChange: (e) => setName(e.target.value), onBlur: save, onKeyDown: (e) => { if (e.key === "Enter") save(); else if (e.key === "Escape") setName(base); } })),
459
+ IconButton("close", "删除", () => onRemove(x)));
460
+ }
461
+
462
+ /** 表情包:她收藏的 + 主人自己放进 _stickers 的(动态页底部) */
428
463
  function StickerShelf({ api, apiBase, notify, relTime, ui }) {
429
- const { h, Group, Tag, Empty, IconButton, Muted } = ui;
464
+ const { h, Group, Tag, Empty, Row, Button, Muted } = ui;
430
465
  const [data, load] = usePoll(api, "/stickers/state", 15000);
466
+ const [busy, setBusy] = react.useState(false);
467
+ const picker = react.useRef(null);
431
468
  if (!data || data.ok === false) return null;
432
- const items = (data.items ?? []).filter((x) => x.exists);
433
- if (items.length === 0 && data.config?.collect === false) return null;
469
+ // 最近的排前面:主人刚放进目录的图不会被收藏记录挤出前 60 张
470
+ const items = (data.items ?? []).filter((x) => x.exists).slice().sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
434
471
  async function remove(x) {
435
472
  const r = await api("POST", "/stickers/delete", { file: x.file, workspacePath: x.workspacePath });
436
473
  if (r?.ok) { notify("删掉了"); load(); } else notify(r?.error ?? "删除失败");
437
474
  }
438
- const now = Date.now();
439
- return Group({ title: "她收藏的表情", aside: items.length ? Tag(String(items.length), "outline") : null },
440
- items.length === 0 ? Empty("还没收藏过。聊天里看到喜欢的表情,她会自己存下来") : h("div", { className: "fs-stickers" }, items.slice(0, 60).map((x) => h("figure", { className: "fs-sticker", key: `${x.workspacePath}/${x.file}`, title: [x.file, x.from && `从 ${x.from} 那里`, x.summary, relTime(x.at, now)].filter(Boolean).join("\n") },
441
- h("img", { alt: x.file, loading: "lazy", src: `${apiBase}/stickers/file?file=${encodeURIComponent(x.file)}&ws=${encodeURIComponent(x.workspacePath)}` }),
442
- h("figcaption", null, Muted(x.file.replace(/\.[a-z]+$/i, ""))),
443
- IconButton("close", "删除", () => remove(x))))));
475
+ async function importFiles(files) {
476
+ const list = Array.from(files ?? []);
477
+ if (list.length === 0) return;
478
+ setBusy(true);
479
+ let doneCount = 0; let skipped = 0; let failed = 0; let full = false;
480
+ for (const f of list) {
481
+ if (f.size > 4 * 1024 * 1024) { skipped++; continue; }
482
+ const b64 = await readBase64(f);
483
+ if (b64 === null) { failed++; continue; }
484
+ const r = await api("POST", "/stickers/import", { name: f.name.replace(/\.[a-z]+$/i, ""), mediaType: f.type, base64: b64 });
485
+ if (r?.ok) doneCount++;
486
+ else if (r?.full === true) { failed++; full = true; break; }
487
+ else failed++;
488
+ }
489
+ setBusy(false);
490
+ const parts = [];
491
+ if (doneCount > 0) parts.push(`导入了 ${doneCount} 张`);
492
+ if (skipped > 0) parts.push(`${skipped} 张超过 4MB 跳过`);
493
+ if (failed > 0) parts.push(`${failed} 张没导进去`);
494
+ if (full) parts.push("_stickers 满了");
495
+ notify(parts.length > 0 ? parts.join(",") : "没有导入");
496
+ load();
497
+ }
498
+ return Group({ title: "表情包", aside: items.length ? Tag(String(items.length), "outline") : null },
499
+ 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); } }),
501
+ Button({ variant: "ghost", disabled: busy, "aria-label": "导入本地图片", onClick: () => picker.current?.click() }, busy ? "导入中…" : "选图导入"),
502
+ Muted("不占每天收藏的额度")),
503
+ 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 }))));
444
504
  }
445
505
 
446
506
  /** 一个群 / 私聊学到的说法 */
package/lib/panel.css CHANGED
@@ -47,13 +47,10 @@ html[data-ds-theme-source] body:not([data-ds-dark-theme]) :is(.fs,.fs-page-shell
47
47
  .fs-tab[aria-selected=true]{border-color:var(--fs-line);background:var(--bg-control);color:var(--fg-primary);font-weight:500}
48
48
  .fs-tab[aria-selected=true] .fs-tab-key{border-color:var(--fs-line-strong);color:var(--fg-primary)}
49
49
  @media (hover:hover){.fs-tab:hover{background:var(--bg-hover);color:var(--fg-primary)}}
50
- .fs-side-foot{margin-top:auto;padding-top:8px;border-top:1px solid var(--fs-line)}
51
- .fs-ver{display:flex;align-items:center;gap:6px;width:100%;height:26px;padding:0 8px;border:0;border-radius:6px;background:transparent;color:var(--fg-tertiary);cursor:pointer}
52
- @media (hover:hover){.fs-ver:hover{background:var(--bg-hover);color:var(--fg-secondary)}}
53
50
  .fs.nav-collapsed .fs-side{padding:8px 6px}
54
51
  .fs.nav-collapsed .fs-side-head{justify-content:center;padding:0}
55
52
  .fs.nav-collapsed .fs-tab-label{display:none}
56
- .fs.nav-collapsed .fs-tab,.fs.nav-collapsed .fs-ver{justify-content:center;padding:0}
53
+ .fs.nav-collapsed .fs-tab{justify-content:center;padding:0}
57
54
 
58
55
  /* 主任务流 */
59
56
  .fs-main{display:flex;flex-direction:column;gap:20px;min-width:0;width:100%;max-width:760px;justify-self:center;padding:14px 24px 24px}
@@ -88,6 +85,13 @@ html[data-ds-theme-source] body:not([data-ds-dark-theme]) :is(.fs,.fs-page-shell
88
85
  .fs-row.stack .fs-ctl{justify-content:flex-start}
89
86
  .fs-unit{font-family:var(--fs-mono);font-variant-numeric:tabular-nums;font-size:12px;line-height:18px;color:var(--fg-secondary);white-space:normal;overflow-wrap:anywhere}
90
87
 
88
+ /* 首次引导:沿用宿主的细边框与低对比背景,窄屏标题允许换行。 */
89
+ .fs-onboarding{padding:10px 12px;border:1px solid var(--fs-line);border-radius:8px;background:var(--bg-control)}
90
+ .fs-onboarding .fs-group-head{min-height:28px;padding-bottom:5px}
91
+ .fs-onboarding .fs-item{min-height:38px;gap:8px}
92
+ .fs-onboarding .fs-item-main{white-space:normal;overflow-wrap:anywhere}
93
+ .fs-onboarding .fs-item-meta{gap:4px}
94
+
91
95
  /* 输入框:settings-form 的 input */
92
96
  .fs-input{flex:0 1 240px;width:240px;min-width:0;height:28px;padding:0 10px;border:1px solid var(--fs-line-strong);border-radius:6px;background:var(--bg-control);font-size:13px;line-height:20px;color:var(--fg-primary)}
93
97
  .fs-input::placeholder{color:var(--fg-tertiary);opacity:.7}
@@ -111,8 +115,16 @@ select.fs-input{appearance:none;-webkit-appearance:none;padding-right:32px;curso
111
115
  .fs-switch[aria-checked=false] .fs-switch-thumb{background:var(--fg-secondary)}
112
116
  .fs-switch[aria-checked=true] .fs-switch-thumb{transform:translateX(16px)}
113
117
 
118
+ /* 官方控件只补充插件布局与危险态,视觉主体来自宿主。 */
119
+ .fs-native-btn{flex:none}
120
+ .fs-native-btn.fs-native-danger{color:var(--status-error)}
121
+ /* 白字主按钮使用稍深的蓝,确保浅色宿主中仍有足够对比度。 */
122
+ .fs .fs-native-btn.fs-native-primary:not(:disabled){background:#4663f3;color:#fff}
123
+ @media (hover:hover){.fs .fs-native-btn.fs-native-primary:hover:not(:disabled){background:#3b57e7}}
124
+ .fs-native-switch[aria-checked=true]{background:var(--accent)}
125
+
114
126
  /* Button(sm):outline / primary / ghost / danger */
115
- .fs-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;gap:4px;height:26px;padding:0 10px;border:0;border-radius:6px;background:transparent;box-shadow:inset 0 0 0 1px var(--fs-line-strong);color:var(--fg-primary);font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}
127
+ .fs-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;gap:4px;height:28px;padding:0 10px;border:0;border-radius:6px;background:transparent;box-shadow:inset 0 0 0 1px var(--fs-line-strong);color:var(--fg-primary);font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}
116
128
  .fs-btn.primary{box-shadow:none;background:var(--accent);color:var(--fg-on-accent)}
117
129
  .fs-btn.ghost{box-shadow:none;color:var(--fg-secondary)}
118
130
  .fs-btn.danger{color:var(--status-error)}
@@ -227,6 +239,7 @@ a.fs-btn{text-decoration:none}
227
239
  .fs-sticker{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px 4px;border:1px solid var(--fs-line);border-radius:6px;background:var(--bg-sidebar)}
228
240
  .fs-sticker img{width:64px;height:64px;object-fit:contain}
229
241
  .fs-sticker figcaption{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
242
+ .fs-sticker figcaption .fs-input{flex:none;width:100%;height:24px;padding:0 4px;font-size:12px;text-align:center}
230
243
  .fs-sticker .fs-icon-btn{position:absolute;top:2px;right:2px;opacity:0}
231
244
  .fs-sticker:hover .fs-icon-btn,.fs-sticker:focus-within .fs-icon-btn{opacity:1}
232
245
  /* 人格页:模板卡片、说话示例气泡、世界书编辑 */
@@ -277,7 +290,7 @@ summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90
277
290
 
278
291
  /* 窄容器:侧栏变顶部横条,详情面板落到下方 */
279
292
  @container (max-width:720px){
280
- .fs,.fs.has-detail,.fs.nav-collapsed{grid-template-columns:minmax(0,1fr)}
293
+ .fs,.fs.has-detail,.fs.nav-collapsed{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}
281
294
  .fs-side{position:sticky;top:0;z-index:4;padding:6px;border-right:0;border-bottom:1px solid var(--fs-line);border-radius:12px 12px 0 0}
282
295
  .fs-side-inner{position:static;flex-direction:row;align-items:center;gap:6px;min-height:0}
283
296
  .fs-main-head{position:static;height:28px;margin-top:0;padding-top:0;padding-bottom:10px}
@@ -288,8 +301,6 @@ summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90
288
301
  .fs-tab-key{display:none}
289
302
  .fs.nav-collapsed .fs-tab-label{display:inline}
290
303
  .fs.nav-collapsed .fs-tab{padding:0 8px}
291
- .fs-side-foot{flex:none;margin:0;padding:0;border:0}
292
- .fs-ver .fs-tab-label{display:none}
293
304
  .fs-main{padding:12px 14px 18px}
294
305
  .fs-group-head{height:auto;min-height:24px;flex-wrap:wrap;gap:2px 8px}
295
306
  .fs-group-head>:first-child{flex:none}
@@ -301,3 +312,103 @@ summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90
301
312
  .fs-dock .fs-input{flex:1 1 auto;width:auto}
302
313
  }
303
314
  @media (prefers-reduced-motion:reduce){summary.fs-step-head>svg{transition:none}}
315
+
316
+ /* 页面层次:把长表单分成清晰、安静的工作区。 */
317
+ .fs{--fs-side:200px}
318
+ .fs-main{max-width:800px;padding-right:32px;padding-left:32px;gap:24px}
319
+ .fs-pane{gap:24px}
320
+ .fs-settings .fs-main-head{margin-top:0;padding-top:0;height:34px}
321
+ .fs-life-overview,.fs-life-settings,.fs-model-routes{min-width:0;padding:16px;border:1px solid var(--fs-line);border-radius:10px;background:var(--bg-surface)}
322
+ .fs-life-overview{background:var(--bg-control)}
323
+ .fs-now{display:flex;align-items:flex-start;gap:12px;padding:0 0 16px}
324
+ .fs-now-activity{display:flex;align-items:flex-start;gap:8px;min-width:0;flex:1 1 260px}
325
+ .fs-now-activity .fs-dot{margin-top:5px}
326
+ .fs-now-title{min-width:0;font-size:14px;line-height:21px;font-weight:500;overflow-wrap:anywhere}
327
+ .fs-now-meta{flex:none;margin-left:auto;display:flex;align-items:center;gap:8px}
328
+ .fs-schedule{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:6px;border:0;border-radius:0;overflow:visible}
329
+ .fs-slot{gap:4px;min-height:66px;padding:9px 10px;border:1px solid var(--fs-line);border-radius:6px;background:var(--bg-surface)}
330
+ .fs-slot:first-child{border-left:1px solid var(--fs-line)}
331
+ .fs-slot>span{white-space:normal;overflow-wrap:anywhere}
332
+ .fs-slot>span:last-child{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
333
+ .fs-slot[aria-current=true]{background:var(--bg-sidebar);border-color:var(--fs-line-strong);box-shadow:none}
334
+ .fs-model-routes .fs-group-head{height:auto;min-height:32px;gap:8px 16px;flex-wrap:wrap}
335
+ .fs-model-routes .fs-group-head>:nth-child(2){min-width:0;max-width:100%;font-weight:400;text-align:right;overflow-wrap:anywhere}
336
+ .fs-model-routes .fs-row{min-height:42px}
337
+
338
+ @container (max-width:720px){
339
+ .fs-main{padding:12px 14px 20px;gap:20px}
340
+ .fs-life-overview,.fs-life-settings,.fs-model-routes{padding:12px}
341
+ .fs-now-meta{margin-left:0}
342
+ }
343
+ /* 小高度窗口可滚动侧栏导航。 */
344
+ .fs-side-inner{min-height:min(464px,calc(100vh - 48px));max-height:calc(100vh - 48px);overflow-y:auto;scrollbar-width:thin}
345
+ @container (max-width:720px){.fs-side-inner{min-height:0;max-height:none;overflow:visible}}
346
+
347
+ /* 官方原语适配:只补齐工作台布局,不接管组件事件。 */
348
+ .fs-native-input-wrap{display:inline-flex;flex:0 1 240px;width:240px;min-width:0}
349
+ .fs-native-input-wrap>.fs-native-input{width:100%;min-width:0;height:28px;padding:0 10px;border:1px solid var(--fs-line-strong);border-radius:6px;background:var(--bg-control)}
350
+ .fs-native-input-wrap>.fs-native-input:focus-within{border-color:var(--accent)}
351
+ .fs-native-input-wrap>.fs-native-input>input{width:100%;min-width:0;font-size:13px;line-height:20px;color:var(--fg-primary)}
352
+ .fs-native-input-wrap>.fs-native-input>input::placeholder{color:var(--fg-tertiary);opacity:.7}
353
+ .fs-native-input-wrap.num{flex:none;width:64px}
354
+ .fs-native-input-wrap.num>.fs-native-input{padding:0 8px}
355
+ .fs-native-input-wrap.num input,.fs-native-input-wrap input[inputmode=numeric]{font-family:var(--fs-mono);font-variant-numeric:tabular-nums}
356
+ .fs-native-input-wrap.num input{text-align:center}
357
+ .fs-native-input-wrap.wide,.fs-row.stack .fs-native-input-wrap:not(.num){flex:1 1 auto;width:100%}
358
+ .fs-affix .fs-native-input-wrap{flex:1 1 auto;width:100%}
359
+ .fs-affix .fs-native-input-wrap>.fs-native-input{padding-right:36px}
360
+ .fs-dock .fs-native-input-wrap{flex:1 1 auto;width:auto}
361
+ .fs-dock .fs-native-input-wrap>.fs-native-input{border-color:transparent;background:transparent}
362
+ .fs-native-icon{width:24px;height:24px;padding:0}
363
+ .fs-native-tag-wrap{display:inline-flex;min-width:0}
364
+ .fs-native-tag{gap:4px;font-family:var(--fs-mono);font-variant-numeric:tabular-nums}
365
+ .fs-native-tag>button{display:inline-flex;padding:0;border:0;background:none;color:inherit;cursor:pointer}
366
+ .fs-native-check.label-hidden>span{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
367
+ @container (max-width:720px){.fs-native-input-wrap:not(.num){flex:1 1 100%;width:100%}.fs-dock .fs-native-input-wrap{flex:1 1 auto;width:auto}}
368
+
369
+ .fs-onboarding-dot{display:inline-flex;flex:none}
370
+ .fs-native-tag .fs-icon-btn,.fs-tag .fs-icon-btn{width:16px;height:16px;min-width:16px;padding:0}
371
+
372
+ /* 主页面:单层横向导航,与宿主侧栏分工;设置页布局保持不变。 */
373
+ .fs.fs-standalone,.fs.fs-standalone.has-detail{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}
374
+ .fs-standalone .fs-side{position:sticky;top:0;z-index:4;align-self:start;min-width:0;padding:7px 12px;border-right:0;border-bottom:1px solid var(--fs-line);border-radius:12px 12px 0 0;background:var(--bg-surface)}
375
+ .fs-standalone .fs-side-inner{position:static;display:flex;flex-direction:row;align-items:center;gap:14px;min-height:0;max-height:none;overflow:visible}
376
+ .fs-standalone .fs-side-head{height:32px;margin:0;padding:0 4px;flex:none}
377
+ .fs-standalone .fs-nav{flex:1 1 auto;min-width:0;display:flex;flex-direction:row;flex-wrap:nowrap;gap:3px;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}
378
+ .fs-standalone .fs-nav::-webkit-scrollbar{display:none}
379
+ .fs-standalone .fs-tab{flex:none;height:32px;padding:0 10px;gap:6px}
380
+ .fs-standalone .fs-tab-key{display:none}
381
+ .fs-standalone .fs-main-head{position:static;height:34px;margin-top:0;padding-top:0;padding-bottom:10px}
382
+ .fs-update-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--status-warn)}
383
+ @container (max-width:720px){
384
+ .fs-standalone .fs-side{padding:6px}
385
+ .fs-standalone .fs-side-inner{gap:6px}
386
+ .fs-standalone .fs-side-head{padding:0 2px}
387
+ .fs-standalone .fs-tab{padding:0 8px}
388
+ }
389
+
390
+ /* 两级导航:分类和对应页面一起吸顶,内容独立滚动。 */
391
+ .fs.fs-standalone,.fs.fs-settings,.fs.fs-standalone.has-detail,.fs.fs-settings.has-detail{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}
392
+ .fs.fs-standalone .fs-side,.fs.fs-settings .fs-side{position:sticky;top:0;z-index:4;align-self:start;min-width:0;padding:0 16px;border-right:0;border-bottom:1px solid var(--fs-line);border-radius:12px 12px 0 0;background:var(--bg-surface)}
393
+ .fs.fs-standalone .fs-side-inner,.fs.fs-settings .fs-side-inner{position:static;display:block;min-height:0;max-height:none;overflow:visible}
394
+ .fs-group-row{display:flex;align-items:center;gap:18px;min-width:0;height:48px;border-bottom:1px solid var(--fs-line)}
395
+ .fs-group-row .fs-brand{flex:none;padding:0 2px;font-size:14px}
396
+ .fs-group-nav{display:flex;align-self:stretch;align-items:stretch;gap:8px;flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}
397
+ .fs-group-nav::-webkit-scrollbar{display:none}
398
+ .fs-group-tab{display:inline-flex;position:relative;flex:none;align-items:center;gap:6px;height:100%;padding:0 8px;border:0;background:transparent;color:var(--fg-secondary);font:inherit;white-space:nowrap;cursor:pointer}
399
+ .fs-group-tab[aria-pressed=true]{color:var(--fg-primary);font-weight:600}
400
+ .fs-group-tab[aria-pressed=true]::after{content:"";position:absolute;right:8px;bottom:0;left:8px;height:2px;border-radius:2px;background:var(--fg-primary)}
401
+ @media (hover:hover){.fs-group-tab:hover{color:var(--fg-primary)}}
402
+ .fs-subnav-row{display:flex;align-items:center;min-width:0;min-height:44px}
403
+ .fs.fs-standalone .fs-nav,.fs.fs-settings .fs-nav{flex:1 1 auto;min-width:0;display:flex;flex-direction:row;flex-wrap:nowrap;gap:6px;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}
404
+ .fs.fs-standalone .fs-nav::-webkit-scrollbar,.fs.fs-settings .fs-nav::-webkit-scrollbar{display:none}
405
+ .fs.fs-standalone .fs-tab,.fs.fs-settings .fs-tab{flex:none;height:28px;padding:0 10px;gap:6px;border-radius:999px}
406
+ .fs.fs-standalone .fs-tab-key,.fs.fs-settings .fs-tab-key{display:none}
407
+ .fs.fs-standalone .fs-main-head,.fs.fs-settings .fs-main-head{position:static;height:34px;margin-top:0;padding-top:0;padding-bottom:10px}
408
+ @container (max-width:720px){
409
+ .fs.fs-standalone .fs-side,.fs.fs-settings .fs-side{padding:0 12px}
410
+ .fs-group-row{height:44px;gap:8px}
411
+ .fs-group-nav{gap:2px}
412
+ .fs-group-tab{padding:0 8px}
413
+ .fs-subnav-row{min-height:42px}
414
+ }
package/lib/routes-v07.js CHANGED
@@ -7,22 +7,28 @@
7
7
  * - 首次引导:检查清单 + 不再显示。
8
8
  */
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
- import { join, basename } from 'node:path';
10
+ import { join, basename, resolve } from 'node:path';
11
11
  import { FEATURES } from './llm.js';
12
12
  import { voiceConfig } from './voice.js';
13
13
  import { stickerConfig } from './stickers.js';
14
14
  import { chatKind, slangConfig, GLOBAL_ID } from './slang.js';
15
15
  import { WEB_PROVIDERS, webSearchConfig, ownSearchReady } from './websearch.js';
16
16
  import { listChatSessions } from './db.js';
17
- import { previewMaiBotExtras, importMaiBotExtras } from './maibot-extras.js';
17
+ import { previewMaiBotExtras, importMaiBotExtras, sniffImage } from './maibot-extras.js';
18
18
  import { maskKey } from './routes.js';
19
19
 
20
20
  const BODY_MAX = 256 * 1024;
21
+ const IMPORT_BODY_MAX = 6 * 1024 * 1024; // 一张图的 base64 + JSON 兜底上限
22
+ const IMPORT_BYTES_MAX = 4 * 1024 * 1024; // 解码后的图片上限
21
23
  const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
24
+ const MEDIA_TYPES = new Set(Object.values(MIME));
22
25
 
23
26
  const isMasked = (s) => typeof s === 'string' && s.includes('••');
24
27
  const str = (x, max = 200) => (typeof x === 'string' ? x.trim().slice(0, max) : '');
25
28
 
29
+ /** base64 严一点:只认标准字母表 + 合法长度,别的编码直接拒。 */
30
+ const isBase64 = (s) => s.length > 0 && s.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(s);
31
+
26
32
  /** 按功能选的模型:只留合法的 { provider, model },空 = 跟 DSH 默认。 */
27
33
  function cleanModels(raw) {
28
34
  const out = {};
@@ -78,6 +84,19 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
78
84
  return JSON.parse(raw || '{}');
79
85
  };
80
86
  const guard = () => { if (!sameOrigin(req)) { send(403, { ok: false, error: 'forbidden' }); return false; } return true; };
87
+ /** 同一个工作区?路径写法可能不一样(相对 / 绝对),比绝对路径。 */
88
+ const sameWorkspace = (a, b) => {
89
+ const x = String(a ?? ''); const y = String(b ?? '');
90
+ if (x === '' || y === '') return false;
91
+ try { return resolve(x) === resolve(y); } catch { return x === y; }
92
+ };
93
+ /** ws 得是认得的工作区(当前工作区 / 收藏记录里出现过的),不能随便指个目录来读、改、删文件。 */
94
+ const knownWorkspace = (p) => {
95
+ const ws = String(p ?? '');
96
+ if (ws === '') return false;
97
+ if (sameWorkspace(ws, gateway?.workspacePath?.() ?? '')) return true;
98
+ return (stickers?.collected() ?? []).some((x) => sameWorkspace(x.workspacePath, ws));
99
+ };
81
100
  const webState = () => {
82
101
  const w = webConfig?.() ?? webSearchConfig({});
83
102
  return {
@@ -204,20 +223,18 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
204
223
  // ── 契约 26~28:表情包 ──
205
224
  if (path === '/stickers/state' && method === 'GET') {
206
225
  if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
207
- const items = stickers.collected().map((x) => ({
208
- file: x.file, workspacePath: x.workspacePath, from: x.from ?? '', summary: x.summary ?? '', at: x.at,
209
- exists: existsSync(join(x.workspacePath, '_stickers', x.file))
210
- }));
211
226
  const ws = gateway?.workspacePath?.() ?? '';
227
+ const items = stickers.list(ws);
212
228
  send(200, { ok: true, config: stickerConfig(getConfig().stickers), items, workspacePath: ws, inWorkspace: ws ? (gateway?.listStickers?.(ws) ?? []).length : 0 });
213
229
  return true;
214
230
  }
215
231
  if (path === '/stickers/file' && method === 'GET') {
216
232
  const file = basename(String(url.searchParams.get('file') ?? ''));
217
233
  const ws = String(url.searchParams.get('ws') ?? '');
218
- const hit = stickers?.collected().find((x) => x.file === file && x.workspacePath === ws);
234
+ // 只有清单里真实存在的才给读(收藏的 + 主人自己放进 _stickers 的)
235
+ const hit = knownWorkspace(ws) ? stickers?.list(ws).find((x) => x.file === file && sameWorkspace(x.workspacePath, ws) && x.exists === true) : null;
219
236
  const ext = file.split('.').pop()?.toLowerCase() ?? '';
220
- const full = hit ? join(ws, '_stickers', file) : '';
237
+ const full = hit ? join(hit.workspacePath, '_stickers', file) : '';
221
238
  if (!hit || !MIME[ext] || !existsSync(full)) { send(404, { ok: false, error: 'not found' }); return true; }
222
239
  res.writeHead(200, { 'content-type': MIME[ext], 'cache-control': 'private, max-age=300' });
223
240
  res.end(readFileSync(full));
@@ -233,11 +250,45 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
233
250
  send(200, { ok: true, config: stickerConfig({ ...(getConfig().stickers ?? {}), ...s }) });
234
251
  return true;
235
252
  }
253
+ if (path === '/stickers/rename' && post) {
254
+ if (!guard()) return true;
255
+ const b = await body();
256
+ if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
257
+ const ws = String(b.workspacePath ?? '');
258
+ if (!knownWorkspace(ws)) { send(200, { ok: false, error: '没有这张' }); return true; }
259
+ const r = stickers.rename(basename(String(b.file ?? '')), String(b.name ?? ''), ws);
260
+ send(200, r.ok ? { ok: true, file: r.file } : { ok: false, error: r.reason ?? '改名失败' });
261
+ return true;
262
+ }
236
263
  if (path === '/stickers/delete' && post) {
237
264
  if (!guard()) return true;
238
265
  const b = await body();
239
- const ok = stickers?.remove(basename(String(b.file ?? '')), String(b.workspacePath ?? '')) === true;
240
- send(200, ok ? { ok: true } : { ok: false, error: '没有这张(只能删她自己收藏的)' });
266
+ const ws = String(b.workspacePath ?? '');
267
+ const ok = knownWorkspace(ws) && stickers?.remove(basename(String(b.file ?? '')), ws) === true;
268
+ send(200, ok ? { ok: true } : { ok: false, error: '没有这张' });
269
+ return true;
270
+ }
271
+ // 主人从面板导入本地图片:只进当前工作区,不占每天收藏的额度
272
+ if (path === '/stickers/import' && post) {
273
+ if (!guard()) return true;
274
+ if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
275
+ const ws = gateway?.workspacePath?.() ?? '';
276
+ if (ws === '') { send(200, { ok: false, error: 'QQ 工作区还没准备好(先连一次 QQ)' }); return true; }
277
+ let raw = '';
278
+ try { raw = await readBody(req); } catch { send(200, { ok: false, error: '请求没读完' }); return true; }
279
+ if (raw.length > IMPORT_BODY_MAX) { send(200, { ok: false, error: '图片太大了(超过 4MB)' }); return true; }
280
+ let b;
281
+ try { b = JSON.parse(raw || '{}'); } catch { send(200, { ok: false, error: '请求格式不对' }); return true; }
282
+ const b64 = String(b.base64 ?? '');
283
+ if (!isBase64(b64)) { send(200, { ok: false, error: '图片编码不对' }); return true; }
284
+ const bytes = Buffer.from(b64, 'base64');
285
+ if (bytes.length === 0) { send(200, { ok: false, error: '没有图片内容' }); return true; }
286
+ if (bytes.length > IMPORT_BYTES_MAX) { send(200, { ok: false, error: '图片太大了(超过 4MB)' }); return true; }
287
+ if (b.mediaType !== undefined && b.mediaType !== '' && !MEDIA_TYPES.has(String(b.mediaType))) { send(200, { ok: false, error: '图片类型不对' }); return true; }
288
+ const mediaType = sniffImage(bytes);
289
+ if (mediaType === null) { send(200, { ok: false, error: '不是支持的图片格式' }); return true; }
290
+ const r = stickers.importImage({ bytes, mediaType, name: String(b.name ?? ''), workspacePath: ws, from: '主人' });
291
+ send(200, r.ok ? { ok: true, file: r.file } : { ok: false, error: r.reason ?? '导入失败', full: r.full === true, dup: r.dup === true });
241
292
  return true;
242
293
  }
243
294
 
package/lib/stickers.js CHANGED
@@ -4,16 +4,25 @@
4
4
  * 网关把每轮转进会话的图片(字节 + 是不是表情)记在这里;模型在回复里写一行 [收藏表情:名字],
5
5
  * 就把这轮(或最近 10 分钟内)对方发的那张表情存到工作区 _stickers/名字.扩展名,以后能用 ![名字](_stickers/…) 发。
6
6
  * 同一张图(sha1)只存一次;每天最多存 perDay 张;_stickers 里最多 300 张。
7
+ * _stickers 目录里的文件本身就是本体:主人自己放进去的图也在面板清单里(不写 kv),能改名、能删。
7
8
  */
8
9
  import { createHash } from 'node:crypto';
9
- import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
10
- import { join } from 'node:path';
10
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { basename, extname, join, resolve } from 'node:path';
11
12
  import { getKv, setKv } from './db.js';
12
13
 
13
14
  export const COLLECT_RE = /\[(?:收藏表情|存表情)\s*[::]\s*([^\]\n]{1,30})\]/g;
14
15
  const RECENT_MS = 10 * 60_000;
15
16
  const DIR_CAP = 300;
16
17
  const EXT = { 'image/png': 'png', 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
18
+ const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
19
+
20
+ /** 同一个工作区?路径写法可能不一样(相对 / 绝对),比绝对路径。 */
21
+ const sameWs = (a, b) => {
22
+ const x = String(a ?? ''); const y = String(b ?? '');
23
+ if (x === '' || y === '') return false;
24
+ try { return resolve(x) === resolve(y); } catch { return x === y; }
25
+ };
17
26
 
18
27
  /** 回复里的 [收藏表情:名字] 抠出来(发出去的文字里去掉)。 */
19
28
  export function parseCollect(text) {
@@ -60,6 +69,59 @@ export class StickerBox {
60
69
  return getKv(this.db, 'stickers.collected', []) ?? [];
61
70
  }
62
71
 
72
+ /**
73
+ * 面板清单:收藏记录(含文件已经没了的)+ 当前工作区里主人自己放进 _stickers 的图。
74
+ * 目录里的图不写 kv、不定时扫,面板打开时现算。
75
+ * @returns {{file: string, workspacePath: string, from: string, summary: string, at: number, exists: boolean, tracked: boolean}[]}
76
+ */
77
+ list(workspacePath) {
78
+ const ws = String(workspacePath ?? '').trim();
79
+ const rows = this.collected();
80
+ const items = rows.map((x) => ({
81
+ file: x.file, workspacePath: x.workspacePath, from: x.from ?? '', summary: x.summary ?? '', at: x.at ?? 0,
82
+ exists: existsSync(join(x.workspacePath, '_stickers', x.file)), tracked: true
83
+ }));
84
+ if (ws === '') return items;
85
+ const known = new Set(rows.filter((x) => sameWs(x.workspacePath, ws)).map((x) => x.file));
86
+ let names = [];
87
+ try { names = readdirSync(join(ws, '_stickers')); } catch { return items; }
88
+ for (const name of names) {
89
+ if (known.has(name) || !IMAGE_EXT.has(extname(name).slice(1).toLowerCase())) continue;
90
+ let at = 0;
91
+ try { at = statSync(join(ws, '_stickers', name)).mtimeMs; } catch { /* 读不到就当没有 */ }
92
+ items.push({ file: name, workspacePath: ws, from: '主人放的', summary: '', at, exists: true, tracked: false });
93
+ }
94
+ return items;
95
+ }
96
+
97
+ /**
98
+ * 改名字:文件真的在 _stickers 里才改;撞名直接拒绝(不自动加序号);收藏记录里的文件名跟着改,主人自己放的不写 kv。
99
+ * @returns {{ok: boolean, file?: string, reason?: string}}
100
+ */
101
+ rename(file, newName, workspacePath) {
102
+ const ws = String(workspacePath ?? '');
103
+ const old = basename(String(file ?? ''));
104
+ if (old === '') return { ok: false, reason: '没有这张' };
105
+ const all = this.collected();
106
+ const hit = all.find((x) => x.file === old && sameWs(x.workspacePath, ws));
107
+ const dir = join(hit?.workspacePath ?? ws, '_stickers');
108
+ const from = join(dir, old);
109
+ if (!existsSync(from)) return { ok: false, reason: '没有这张' };
110
+ const ext = extname(old).slice(1).toLowerCase();
111
+ if (!IMAGE_EXT.has(ext)) return { ok: false, reason: '只支持图片文件' };
112
+ const base = stickerName(basename(String(newName ?? '')));
113
+ if (base === '') return { ok: false, reason: '名字不能用' };
114
+ const target = `${base}.${ext}`;
115
+ if (target === old) return { ok: true, file: old };
116
+ if (existsSync(join(dir, target))) return { ok: false, reason: '已经有同名了' };
117
+ try { renameSync(from, join(dir, target)); } catch { return { ok: false, reason: '改名失败' }; }
118
+ if (hit) {
119
+ hit.file = target;
120
+ setKv(this.db, 'stickers.collected', all.slice(0, DIR_CAP));
121
+ }
122
+ return { ok: true, file: target };
123
+ }
124
+
63
125
  /**
64
126
  * 存最近一张(优先表情)为 名字.扩展名。
65
127
  * @returns {{ok: boolean, file?: string, reason?: string}}
@@ -114,13 +176,17 @@ export class StickerBox {
114
176
  return { ok: true, file };
115
177
  }
116
178
 
117
- /** 设置页删一张她收藏的(只删她自己收藏的,不碰主人放的)。 */
179
+ /** 设置页删一张:收藏的和主人自己放的都能删;文件已经没了就把收藏记录也删掉。 */
118
180
  remove(file, workspacePath) {
181
+ const ws = String(workspacePath ?? '');
182
+ const name = basename(String(file ?? ''));
183
+ if (name === '') return false;
119
184
  const all = this.collected();
120
- const hit = all.find((x) => x.file === file && x.workspacePath === workspacePath);
121
- if (!hit) return false;
122
- try { unlinkSync(join(workspacePath, '_stickers', hit.file)); } catch { /* 已经没了 */ }
123
- setKv(this.db, 'stickers.collected', all.filter((x) => x !== hit));
185
+ const hit = all.find((x) => x.file === name && sameWs(x.workspacePath, ws));
186
+ const path = join(hit?.workspacePath ?? ws, '_stickers', name);
187
+ if (!hit && !existsSync(path)) return false;
188
+ try { unlinkSync(path); } catch { /* 已经没了 */ }
189
+ if (hit) setKv(this.db, 'stickers.collected', all.filter((x) => x !== hit));
124
190
  return true;
125
191
  }
126
192
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.15.1",
4
- "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.1) ",
3
+ "version": "0.15.3",
4
+ "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.3) ",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "files": [
@@ -57,12 +57,13 @@
57
57
  "slots"
58
58
  ],
59
59
  "external": [
60
- "react"
60
+ "react",
61
+ "@deepseek-ai/dsh-client-ui-primitives"
61
62
  ]
62
63
  }
63
64
  },
64
65
  "scripts": {
65
- "test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/client-lint.mjs && node test/client-panels.mjs && node test/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs",
66
+ "test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/client-lint.mjs && node test/client-panels.mjs && node test/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs && node test/sticker-manage.mjs",
66
67
  "eval": "node test/eval/persona-eval.mjs"
67
68
  }
68
69
  }