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 +1 -1
- package/lib/client.js +179 -67
- package/lib/client.models.js +1 -1
- package/lib/client.soul.js +75 -15
- package/lib/panel.css +119 -8
- package/lib/routes-v07.js +61 -10
- package/lib/stickers.js +73 -7
- package/package.json +5 -4
package/README.md
CHANGED
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
|
-
* -
|
|
10
|
-
*
|
|
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
|
-
// ──
|
|
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
|
|
99
|
-
|
|
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) =>
|
|
106
|
-
|
|
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) =>
|
|
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) =>
|
|
161
|
-
|
|
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
|
-
|
|
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-
|
|
394
|
+
h("section", { className: "fs-life-overview" },
|
|
332
395
|
h("div", { className: "fs-now" },
|
|
333
|
-
Dot(sleeping ? "idle" : "done"),
|
|
334
|
-
|
|
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
|
-
|
|
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 : "看到喜欢的表情会存下来,之后自己用" },
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
x.done ?
|
|
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,
|
|
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 [
|
|
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 === "
|
|
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
|
|
749
|
-
|
|
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("
|
|
752
|
-
h(
|
|
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 } = {}) {
|
package/lib/client.models.js
CHANGED
|
@@ -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}`) : "没有可用的模型" },
|
package/lib/client.soul.js
CHANGED
|
@@ -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(
|
|
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: [
|
|
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(
|
|
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) =>
|
|
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,
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
240
|
-
|
|
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/名字.扩展名,以后能用  发。
|
|
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 ===
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
4
|
-
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.
|
|
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
|
}
|