dsh-fish-soul 0.15.2 → 0.15.4

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.2**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.4**。
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
  }
@@ -530,13 +593,13 @@ window.__ModuleLoader__.load({
530
593
  Num({ min: 0, max: 12, value: form.perDay, "aria-label": "轮数", onChange: (e) => patch({ perDay: e.target.value }), onBlur: () => save() }), Muted("轮 ×"),
531
594
  Num({ min: 1, max: 6, value: form.videosPerSession, "aria-label": "每轮条数", onChange: (e) => patch({ videosPerSession: e.target.value }), onBlur: () => save() }), Muted("条")) : null,
532
595
  br.enabled !== false ? Row({ title: "喜欢就点赞" }, sw("喜欢就点赞", br.like === true, (v) => save({ browse: { like: v } }))) : null,
533
- br.enabled !== false ? Row({ title: "刷到有意思的发给你", desc: "带链接和感受,发到你的 QQ 私聊;多少条受「主动开口」那边的额度管" }, sw("刷到有意思的发给你", br.share !== false, (v) => save({ browse: { share: v } }))) : null,
534
- br.enabled !== false ? Row({ title: "想再看的就收藏", desc: `收进默认收藏夹,每天最多 ${br.favoritePerDay ?? 2} 个(可改),建议 1–3 个;默认关` },
596
+ br.enabled !== false ? Row({ title: "刷到就分享", desc: "带链接和感受,发到你的 QQ 私聊;多少条受「主动开口」那边的额度管" }, sw("刷到就分享", br.share !== false, (v) => save({ browse: { share: v } }))) : null,
597
+ br.enabled !== false ? Row({ title: "看完就收藏", desc: `收进默认收藏夹,建议 1–3 个/天;要能看图的模型(flash 可以、pro 不行)` },
535
598
  br.favorite ? Muted(`今天收藏了 ${br.favoritedToday ?? 0}`) : null,
536
599
  sw("想再看的就收藏", br.favorite === true, (v) => save({ browse: { favorite: v } })),
537
600
  br.favorite ? Num({ min: 0, max: 10, value: form.favoritePerDay, "aria-label": "一天最多收藏几个", onChange: (e) => patch({ favoritePerDay: e.target.value }), onBlur: () => save() }) : null,
538
601
  br.favorite ? Muted("个/天") : null) : null,
539
- br.enabled !== false ? Row({ title: "看一眼画面", desc: `抽视频开头 1 分钟的九宫格截图给她看(要用能看图的模型,flash 可以、pro 不行);一条十几秒;默认关` },
602
+ br.enabled !== false ? Row({ title: "看一眼画面", desc: `抽开头 1 分钟拼 9 格一起看,一条十几秒;要用能看图的模型(flash 可以、pro 不行)` },
540
603
  br.frames ? Muted(`今天看了 ${br.framedToday ?? 0}`) : null,
541
604
  sw("看一眼画面", br.frames === true, (v) => save({ browse: { frames: v } })),
542
605
  br.frames ? Muted("自己刷的") : null,
@@ -545,8 +608,8 @@ window.__ModuleLoader__.load({
545
608
  br.frames ? Muted("你发链接的") : null,
546
609
  br.frames ? Num({ min: 0, max: 30, value: form.framesPerDayChat, "aria-label": "你发链接时每天最多几条", onChange: (e) => patch({ framesPerDayChat: e.target.value }), onBlur: () => save() }) : null,
547
610
  br.frames ? Muted(`条/天(今天 ${br.framedTodayChat ?? 0})`) : null) : null,
548
- br.enabled !== false ? Row({ title: "回一条别人的评论", stack: true, desc: "在她看的视频评论区里挑一条值得搭话的,回一句(楼中楼);不追着聊、不抬杠;默认关" },
549
- sw("回一条别人的评论", br.commentReply === true, (v) => save({ browse: { commentReply: v } })),
611
+ br.enabled !== false ? Row({ title: "回一条评论", stack: true, desc: "在她看的视频评论区挑一条值得搭话的回一句(楼中楼);不追着聊、不抬杠" },
612
+ sw("回一条评论", br.commentReply === true, (v) => save({ browse: { commentReply: v } })),
550
613
  br.commentReply ? Num({ min: 0, max: 5, value: form.commentReplyPerDay, "aria-label": "一天最多回几条", onChange: (e) => patch({ commentReplyPerDay: e.target.value }), onBlur: () => save() }) : null,
551
614
  br.commentReply ? Muted("条/天") : null,
552
615
  br.commentReply ? Muted(`(今天回了 ${br.repliedToday ?? 0})`) : null,
@@ -561,9 +624,9 @@ window.__ModuleLoader__.load({
561
624
  }, `最近一次:回 @${br.replyLast.name || "某人"}「${String(br.replyLast.text ?? "").slice(0, 20)}」`)
562
625
  : null) : null,
563
626
  br.enabled !== false ? Row({ title: "也看弹幕", desc: "看视频时顺便看几条弹幕(只看不发)" }, sw("也看弹幕", br.danmaku !== false, (v) => save({ browse: { danmaku: v } }))) : null,
564
- br.enabled !== false ? Row({ title: "看完留一句", desc: `真看完、有感觉的视频,在评论区留一句(不超过 25 字,只写不追回复),一天最多 ${br.commentPerDay ?? 1} 条;默认关` },
627
+ br.enabled !== false ? Row({ title: "留一句", desc: `真看完、有感觉的视频,在评论区留一句(不超过 25 字,只写不追回复),一天最多 ${br.commentPerDay ?? 1} 条` },
565
628
  br.comment ? Muted(`今天留了 ${br.commentedToday ?? 0}`) : null,
566
- sw("看完留一句", br.comment === true, (v) => save({ browse: { comment: v } })),
629
+ sw("留一句", br.comment === true, (v) => save({ browse: { comment: v } })),
567
630
  br.comment ? Num({ min: 0, max: 5, value: form.commentPerDay, "aria-label": "一天最多几条", onChange: (e) => patch({ commentPerDay: e.target.value }), onBlur: () => save() }) : null,
568
631
  br.comment ? Muted("条/天") : null) : null,
569
632
  br.enabled !== false ? Row({ title: "你发视频给她时读多细", stack: true, desc: "你在聊天里发 B站 链接,她先看一遍再回;四个数字都可以改,字数是节选上限,不写 0 就行" },
@@ -571,7 +634,7 @@ window.__ModuleLoader__.load({
571
634
  Num({ min: 0, max: 3000, step: 100, value: form.subtitleChars, "aria-label": "字幕字数", onChange: (e) => patch({ subtitleChars: e.target.value }), onBlur: () => save() }), Muted("字字幕,"),
572
635
  Num({ min: 0, max: 1000, step: 50, value: form.descChars, "aria-label": "简介字数", onChange: (e) => patch({ descChars: e.target.value }), onBlur: () => save() }), Muted("字简介,"),
573
636
  Num({ min: 0, max: 500, step: 20, value: form.hotCommentChars, "aria-label": "热评字数", onChange: (e) => patch({ hotCommentChars: e.target.value }), onBlur: () => save() }), Muted("字热评")) : null,
574
- br.enabled !== false ? Row({ title: "按关键词搜一下", desc: "让她偶尔按自己最近感兴趣的东西搜一下,当作刷视频的又一个来源;只读,默认关" },
637
+ br.enabled !== false ? Row({ title: "按关键词搜一下", desc: "让她偶尔按自己最近感兴趣的东西搜一下,当作刷视频的又一个来源;只读" },
575
638
  br.search ? Muted(`今天搜了 ${br.searchedToday ?? 0} / ${br.searchPerDay ?? 2} 次`) : null,
576
639
  sw("按关键词搜一下", br.search === true, (v) => save({ browse: { search: v } }))) : null,
577
640
  br.enabled !== false && br.search ? Row({ title: "搜什么词", desc: "留空 = 她自己按最近看过的提一个;填了就按你的搜(逗号分隔,比如:插件,大肥鱼)" },
@@ -579,15 +642,15 @@ window.__ModuleLoader__.load({
579
642
  br.enabled !== false && br.search ? Row({ title: "一天最多搜" },
580
643
  Num({ min: 0, max: 12, value: form.searchPerDay, "aria-label": "一天最多搜几次", onChange: (e) => patch({ searchPerDay: e.target.value }), onBlur: () => save() }), Muted("次")) : null),
581
644
  Group({ title: "动态和评论", aside: post.enabled ? Tag(`今天发了 ${so.post?.todayCount ?? 0} / ${post.perDay ?? 1}`, "outline") : null },
582
- Row({ title: "自己发动态", desc: "醒着的时候按心情和最近刷到的随手发,纯文字;默认关" },
645
+ Row({ title: "自己发动态", desc: "醒着的时候按心情和最近刷到的随手发,纯文字" },
583
646
  post.enabled ? Button({ variant: "ghost", disabled: busy || so.post?.running === true || b.enabled !== true, onClick: () => act("/bili/post-now", {}, (r) => `发了:${r.text}`, "没发成") }, so.post?.running ? "正在写…" : "现在发一条") : null,
584
647
  sw("自己发动态", post.enabled === true, (v) => save({ post: { enabled: v } }))),
585
648
  post.enabled ? Row({ title: "每天最多" }, Num({ min: 0, max: 5, value: form.postPerDay, "aria-label": "每天最多几条", onChange: (e) => patch({ postPerDay: e.target.value }), onBlur: () => save() }), Muted("条")) : null,
586
649
  Row({ title: "回评论", desc: "别人在她的动态 / 视频下评论或回复她时回一句;广告、引战不回" },
587
650
  cm.enabled ? Button({ variant: "ghost", disabled: busy || b.enabled !== true, onClick: () => act("/bili/comments-now", {}, (r) => (r.replied || r.skipped ? `回了 ${r.replied} 条,跳过 ${r.skipped} 条` : "没有新评论"), "没看成") }, "现在看一眼") : null,
588
651
  sw("回评论", cm.enabled === true, (v) => save({ comments: { enabled: v } }))),
589
- cm.enabled ? Row({ title: "别人评论区 @ 她也回", desc: "在别人的视频 / 动态评论区 @ 她时回一句;回复大家都看得到,默认关" }, sw("别人评论区 @ 她也回", cm.atMe === true, (v) => save({ comments: { atMe: v } }))) : null,
590
- cm.enabled && cm.atMe ? Row({ title: "@ 她时先读一下视频", desc: "把简介和字幕节选塞进提示词再回;多两次 B站 接口调用,默认关" }, sw("@ 她时先读一下视频", cm.atReadVideo === true, (v) => save({ comments: { atReadVideo: v } }))) : null,
652
+ cm.enabled ? Row({ title: "别人评论区 @ 她也回", desc: "在别人的视频 / 动态评论区 @ 她时回一句;回复大家都看得到" }, sw("别人评论区 @ 她也回", cm.atMe === true, (v) => save({ comments: { atMe: v } }))) : null,
653
+ cm.enabled && cm.atMe ? Row({ title: "@ 她时先读一下视频", desc: "把简介和字幕节选塞进提示词再回;多两次 B站 接口调用" }, sw("@ 她时先读一下视频", cm.atReadVideo === true, (v) => save({ comments: { atReadVideo: v } }))) : null,
591
654
  cm.enabled ? Row({ title: "每小时最多" }, Num({ min: 0, max: 30, value: form.commentsPerHour, "aria-label": "每小时最多回几条", onChange: (e) => patch({ commentsPerHour: e.target.value }), onBlur: () => save() }), Muted("条")) : null,
592
655
  cm.enabled ? Row({ title: "回一句大概多长", desc: "给模型的上限提示,不是硬截断;评论区默认 40 字比较像真人" },
593
656
  Num({ min: 10, max: 300, step: 10, value: form.commentsReplyChars, "aria-label": "回评论字数", onChange: (e) => patch({ commentsReplyChars: e.target.value }), onBlur: () => save() }), Muted("字")) : null,
@@ -622,34 +685,51 @@ window.__ModuleLoader__.load({
622
685
  }
623
686
 
624
687
  // ── 面板根组件:页签 + 数据装载 + 轮询 ──
625
- 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: "版" };
626
689
  const TABS = [["retrieval", "记忆检索"], ["persona", "人格"], ["life", "自走"], ["inspector", "记忆检视"], ["activity", "动态"], ["behavior", "习惯"], ["models", "模型"], ["qq", "QQ"], ["bili", "B站"], ["douyin", "抖音"]];
627
-
628
- // ── 首次引导:没点「不再显示」就在页签上面列一张清单 ──
629
- 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 }) {
630
703
  const [data, setData] = react.useState(null);
631
- 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 ?? "引导状态暂不可用"); }, []);
632
706
  react.useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
633
- 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"));
634
708
  async function dismiss() {
635
709
  const r = await api("POST", "/onboarding/dismiss", { dismissed: true });
636
- if (r?.ok) setData({ ...data, dismissed: true }); else notify(r?.error ?? "操作失败");
710
+ if (r?.ok) { setData({ ...data, dismissed: true }); onDismiss(); } else notify(r?.error ?? "操作失败");
637
711
  }
638
- const left = data.steps.filter((x) => !x.done && !x.optional).length;
639
- return Group({ title: left === 0 ? "就绪" : `开始 · ${data.steps.length - left}/${data.steps.length}`, aside: IconButton("close", "不再显示", dismiss) },
640
- h("div", { className: "fs-list" }, data.steps.map((x) => h("div", { className: "fs-item", key: x.id },
641
- h("span", { className: "fs-dot", "data-state": x.done ? "done" : x.optional ? "idle" : "warning", "aria-hidden": true }),
642
- h("span", { className: cx("fs-item-main", x.done && "fs-muted") }, x.title),
643
- h("span", { className: "fs-item-meta" },
644
- x.optional && !x.done ? Tag("可选", "outline") : null,
645
- 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
+ })));
646
725
  }
647
726
  // 版本与更新:显示当前版本,自动 / 手动去 npm(不通再 GitHub)看有没有新版;只提示,不替你装
648
- function UpdateBar({ notify, compact, onOpen }) {
727
+ function UpdateBar({ notify, onUpdateState }) {
649
728
  const [data, setData] = react.useState(null);
650
729
  const [busy, setBusy] = react.useState(false);
651
730
  const load = react.useCallback(async () => { const r = await api("GET", "/update/state"); if (r?.ok) setData(r); }, []);
652
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]);
653
733
  if (!data) return null;
654
734
  async function check() {
655
735
  setBusy(true);
@@ -665,9 +745,6 @@ window.__ModuleLoader__.load({
665
745
  async function copy() {
666
746
  try { await navigator.clipboard.writeText(data.command); notify("已复制更新命令"); } catch { notify("复制失败,手动选中复制"); }
667
747
  }
668
- if (compact) return h("button", { type: "button", className: "fs-ver", onClick: onOpen, title: data.hasUpdate ? `v${data.latest}` : undefined, "aria-label": "版本" },
669
- h("span", { className: "fs-dot", "data-state": data.hasUpdate ? "warning" : data.latest ? "done" : "idle", "aria-hidden": true }),
670
- h("span", { className: "fs-tab-label num" }, `v${data.current}`));
671
748
  const when = data.checkedAt ? new Date(data.checkedAt).toLocaleString("zh-CN", { hour12: false }) : "";
672
749
  const status = data.hasUpdate ? Tag(`有新版本 v${data.latest}`, "warning")
673
750
  : data.latest ? Tag("已是最新", "success")
@@ -698,16 +775,33 @@ window.__ModuleLoader__.load({
698
775
  }
699
776
  }
700
777
 
701
- function FishSoulSection() {
778
+ function FishSoulSection({ standalone = false } = {}) {
702
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);
703
784
  const [tab, setTab] = react.useState("retrieval");
785
+ const hasInteracted = react.useRef(false);
786
+ const goTab = (next) => { hasInteracted.current = true; setTab(next); };
704
787
  const [retrieval, setRetrieval] = react.useState(null);
705
788
  const [life, setLife] = react.useState(null);
706
789
  const [channels, setChannels] = react.useState(null);
707
790
  const [toast, setToast] = react.useState(null);
708
- const [navOpen, setNavOpen] = react.useState(true);
709
- const [detail, setDetail] = react.useState(null);
791
+ const [updateAvailable, setUpdateAvailable] = react.useState(false);
710
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
+ }, []);
711
805
 
712
806
  react.useEffect(() => {
713
807
  if (toast === null) return undefined;
@@ -721,6 +815,14 @@ window.__ModuleLoader__.load({
721
815
  const loadChannels = react.useCallback(load("/channels/state", setChannels), []);
722
816
 
723
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
+ }, []);
724
826
  // 检索页 2s 刷新(重建进度);QQ / B站 / 抖音 3s 刷新
725
827
  react.useEffect(() => {
726
828
  const fn = tab === "retrieval" ? loadRetrieval : tab === "qq" || tab === "bili" || tab === "douyin" ? loadChannels : null;
@@ -736,8 +838,21 @@ window.__ModuleLoader__.load({
736
838
  return () => clearInterval(timer);
737
839
  }, [tab]);
738
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
+
739
851
  if (css === null) return null;
740
- 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 })
741
856
  : tab === "life" ? h(LifePane, { data: life, refresh: loadLife, notify })
742
857
  : tab === "inspector" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.InspectorPane, failText: "记忆检视加载失败", notify })
743
858
  : tab === "activity" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.ActivityPane, failText: "动态加载失败", notify })
@@ -748,30 +863,23 @@ window.__ModuleLoader__.load({
748
863
  : tab === "bili" ? h(BiliPane, { data: channels, refresh: loadChannels, notify })
749
864
  : h(ChunkTab, { file: "client.douyin.js", pick: (m) => m.DouyinPane, failText: "抖音页加载失败", data: channels, refresh: loadChannels, notify });
750
865
 
751
- // 工作台骨架:可折叠侧栏(导航 + 版本)| 主任务流(引导 + 当前页)| 按需展开的详情面板(版本与更新)
752
- const label = TABS.find(([v]) => v === tab)?.[1] ?? "";
753
- 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; } },
754
870
  css === "" ? null : h("style", { "data-plugin-css": `${PLUGIN_ID}/panel.css` }, css),
755
- h("aside", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
756
- h("div", { className: "fs-side-head" },
757
- h("span", { className: "fs-tab-label fs-brand" }, "鱼"),
758
- IconButton("sidebar", navOpen ? "收起侧栏" : "展开侧栏", () => setNavOpen(!navOpen), { "aria-expanded": navOpen })),
759
- h(Tabs, { items: TABS, value: tab, onChange: setTab }),
760
- 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 }))),
761
873
  h("main", { className: "fs-main" },
762
874
  h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label)),
763
- h(Boundary, { name: "引导" }, h(Onboarding, { onGo: setTab, notify })),
764
875
  h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${tab}`, key: tab }, h(Boundary, { name: "这一页" }, pane))),
765
- detail === "version" ? h("aside", { className: "fs-detail", "aria-label": "版本" }, h("div", { className: "fs-detail-inner" },
766
- h("div", { className: "fs-detail-head" }, h("span", null, "版本"), IconButton("close", "关闭", () => setDetail(null))),
767
- h(Boundary, { name: "版本" }, h(UpdateBar, { notify })))) : null,
768
876
  toast === null ? null : h("div", { className: "fs-toast", role: "status", key: toast.key }, toast.text)));
769
877
  }
770
878
 
771
879
  // 同一组件可在设置弹窗或宿主主面板内渲染;只改变承载容器。
772
880
  function FishSoulPage() {
773
881
  return h("div", { className: "fs-page-shell" },
774
- h("div", { className: "fs-page-scroll" }, h(FishSoulSection)));
882
+ h("div", { className: "fs-page-scroll" }, h(FishSoulSection, { standalone: true })));
775
883
  }
776
884
 
777
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 !== "";
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)}
@@ -278,7 +290,7 @@ summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90
278
290
 
279
291
  /* 窄容器:侧栏变顶部横条,详情面板落到下方 */
280
292
  @container (max-width:720px){
281
- .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)}
282
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}
283
295
  .fs-side-inner{position:static;flex-direction:row;align-items:center;gap:6px;min-height:0}
284
296
  .fs-main-head{position:static;height:28px;margin-top:0;padding-top:0;padding-bottom:10px}
@@ -289,8 +301,6 @@ summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90
289
301
  .fs-tab-key{display:none}
290
302
  .fs.nav-collapsed .fs-tab-label{display:inline}
291
303
  .fs.nav-collapsed .fs-tab{padding:0 8px}
292
- .fs-side-foot{flex:none;margin:0;padding:0;border:0}
293
- .fs-ver .fs-tab-label{display:none}
294
304
  .fs-main{padding:12px 14px 18px}
295
305
  .fs-group-head{height:auto;min-height:24px;flex-wrap:wrap;gap:2px 8px}
296
306
  .fs-group-head>:first-child{flex:none}
@@ -302,3 +312,103 @@ summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90
302
312
  .fs-dock .fs-input{flex:1 1 auto;width:auto}
303
313
  }
304
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.15.2",
4
- "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.2) ",
3
+ "version": "0.15.4",
4
+ "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.4)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "files": [
@@ -57,7 +57,8 @@
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
  },