dsh-fish-soul 0.15.0 → 0.15.2
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 +4 -2
- package/lib/client.behavior.js +5 -5
- package/lib/client.js +41 -42
- package/lib/client.models.js +1 -1
- package/lib/client.persona.js +4 -7
- package/lib/client.soul.js +74 -13
- package/lib/panel.css +145 -115
- package/lib/routes-v07.js +61 -10
- package/lib/stickers.js +73 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
|
|
4
4
|
|
|
5
|
-
DSH 插件(单角色 `feiyu`)。版本 **0.15.
|
|
5
|
+
DSH 插件(单角色 `feiyu`)。版本 **0.15.2**。
|
|
6
|
+
|
|
7
|
+
QQ 交流群:**1125962189**
|
|
6
8
|
|
|
7
9
|
## 能力
|
|
8
10
|
|
|
@@ -33,7 +35,7 @@ dsh plugin --profile desktop add dsh-fish-soul
|
|
|
33
35
|
|
|
34
36
|
手工等价:profile 的 `package.json` 把 `dsh-fish-soul` 加进 `dsh.profile.bundles`,并在 `dependencies` 里写 `dsh-fish-soul` 的版本(本地源码则用 `link:` 路径),然后 `pnpm install`。
|
|
35
37
|
|
|
36
|
-
插件注入 `webServer / tools / systemPrompt / llm / agents / workspaceRegistry
|
|
38
|
+
插件注入 `webServer / tools / systemPrompt / llm / agents / workspaceRegistry`。在支持主面板的 DSH 版本中,左侧栏「鱼」可打开独立工作台;设置页入口也保留。旧宿主若不提供侧栏面板插槽,仍可从设置页打开(「人格 / 记忆 / QQ / B站 / 抖音 / 自走 / 模型 / 习惯 / 动态」)。
|
|
37
39
|
|
|
38
40
|
已在 DSH **0.2.1-rc.5** 上验证;更早的 rc 版本未逐一验证,遇到问题请先升级复测。
|
|
39
41
|
|
package/lib/client.behavior.js
CHANGED
|
@@ -59,7 +59,7 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
Group({ title: "QQ 小习惯" },
|
|
60
60
|
Row({ title: "看合并转发", desc: "展开一层,最多看前几条" },
|
|
61
61
|
h(Switch, { label: "看合并转发", checked: cfg.forward.enabled, onChange: (v) => save({ forward: { enabled: v } }) })),
|
|
62
|
-
Row({ title: "被戳了戳回去",
|
|
62
|
+
Row({ title: "被戳了戳回去", desc: "SnowLuma 不支持时不会戳回" },
|
|
63
63
|
ia.pokeBack ? h(NumField, { ui, value: pct(ia.pokeBackChance), min: 0, max: 100, step: 10, label: "戳回去的概率", onSave: (n) => save({ interactions: { pokeBackChance: n / 100 } }) }) : null,
|
|
64
64
|
ia.pokeBack ? Muted("%") : null,
|
|
65
65
|
h(Switch, { label: "被戳了戳回去", checked: ia.pokeBack, onChange: (v) => save({ interactions: { pokeBack: v } }) })),
|
|
@@ -69,7 +69,7 @@ window.__ModuleLoader__.load({
|
|
|
69
69
|
h(Switch, { label: "群里点表情回应", checked: ia.react, onChange: (v) => save({ interactions: { react: v } }) })),
|
|
70
70
|
Row({ title: "私聊显示正在输入" },
|
|
71
71
|
h(Switch, { label: "私聊显示正在输入", checked: ia.typing, onChange: (v) => save({ interactions: { typing: v } }) })),
|
|
72
|
-
Row({ title: "群里冷热",
|
|
72
|
+
Row({ title: "群里冷热", desc: ht.enabled ? `${Math.round(ht.windowSec / 60)} 分钟内有 ${ht.hotCount} 条、${ht.hotSpeakers} 个人在说就算热闹,插话概率 ×${ht.boost};她说完话 ${ht.coolMinutes} 分钟内慢慢恢复` : "插话只看概率和冷却" },
|
|
73
73
|
ht.enabled ? h(NumField, { ui, value: ht.hotCount, min: 3, max: 50, step: 1, label: "热闹的条数", onSave: (n) => save({ heat: { hotCount: n } }) }) : null,
|
|
74
74
|
ht.enabled ? Muted("条") : null,
|
|
75
75
|
h(Switch, { label: "群里冷热", checked: ht.enabled, onChange: (v) => save({ heat: { enabled: v } }) }))));
|
|
@@ -87,8 +87,8 @@ window.__ModuleLoader__.load({
|
|
|
87
87
|
}
|
|
88
88
|
const [label, tone] = HEAT[g.heat?.state] ?? HEAT.off;
|
|
89
89
|
return h("div", { className: "fs-row stack", key: g.groupId },
|
|
90
|
-
h("div", { className: "fs-row-title" }, h("span", null, g.title),
|
|
91
|
-
h("span", { className: "fs-
|
|
90
|
+
h("div", { className: "fs-row-title" }, h("div", { className: "fs-group-note-title" }, h("span", null, g.title),
|
|
91
|
+
h("span", { className: "fs-unit" }, Tag(label, tone), g.heat && g.heat.count > 0 ? Muted(` 最近 ${g.heat.count} 条 · ${g.heat.speakers} 人`) : null))),
|
|
92
92
|
h("div", { className: "fs-ctl" }, Area({ value: text, rows: 2, maxLength: 500, "aria-label": `${g.title} 的群设定`,
|
|
93
93
|
placeholder: "你在这个群是什么身份、气氛和忌讳(只在这个群里给她看),比如:考研群,你是过来人学姐,大家叫你鱼老师;别聊游戏",
|
|
94
94
|
onChange: (e) => setText(e.target.value), onBlur: save })));
|
|
@@ -115,7 +115,7 @@ window.__ModuleLoader__.load({
|
|
|
115
115
|
const hostText = host ? `跟 DSH 默认(${host.model})` : "跟 DSH 默认";
|
|
116
116
|
const cfg = cm.config ?? {};
|
|
117
117
|
const chats = cm.chats ?? [];
|
|
118
|
-
return Group({ title: "聊天用哪个模型",
|
|
118
|
+
return Group({ title: "聊天用哪个模型", tip: "单个聊天 → 私聊 / 群聊默认 → DSH 默认" },
|
|
119
119
|
Row({ title: "私聊默认", desc: "QQ / B站 / 抖音私信" }, Select({ value: val(cfg.private), "aria-label": "私聊默认", onChange: (e) => post({ private: parse(e.target.value) }) }, ...options(cfg.private, hostText))),
|
|
120
120
|
Row({ title: "群聊默认" }, Select({ value: val(cfg.group), "aria-label": "群聊默认", onChange: (e) => post({ group: parse(e.target.value) }) }, ...options(cfg.group, hostText))),
|
|
121
121
|
...chats.map((c) => Row({ title: c.title, key: c.channelId, meta: c.override ? undefined : c.resolved ? `现在用 ${c.resolved.model}` : undefined },
|
package/lib/client.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 宿主契约:
|
|
7
7
|
* - window.__ModuleLoader__.load({ id, factory }),exports.apply / inject;inject: ["slots"]
|
|
8
|
-
* -
|
|
8
|
+
* - 通过 settings.section 保留设置入口;在宿主支持时通过 main + sidebar.panellist 注册独立页;样式随组件挂载 / 卸载
|
|
9
9
|
* - 不 require 任何 Harness Client 包:下面的 Switch / Button / Input / Seg / Tabs / Dot / Tag 照抄
|
|
10
10
|
* @deepseek-ai/dsh-client-ui-primitives 的结构、无障碍语义与样式(lib/panel.css,fs- 前缀,只用 --dsw-alias-* 令牌)
|
|
11
11
|
*
|
|
@@ -149,13 +149,13 @@ window.__ModuleLoader__.load({
|
|
|
149
149
|
return h("div", { className: cx("fs-row", stack && "stack") },
|
|
150
150
|
h("div", { className: "fs-row-title" },
|
|
151
151
|
h("span", { className: cx("fs-row-label", tip && "has-tip"), title: tip }, title),
|
|
152
|
-
desc && !tip ? h("span", { className: "fs-row-desc" }, desc) : null,
|
|
153
152
|
meta ? h("span", { className: "fs-row-meta" }, meta) : null),
|
|
154
153
|
h("div", { className: "fs-ctl" }, ...kids));
|
|
155
154
|
}
|
|
156
|
-
|
|
155
|
+
/** @param {{ title?: any, aside?: any, tip?: string }} props @param {...any} kids */
|
|
156
|
+
function Group({ title, aside, tip }, ...kids) {
|
|
157
157
|
return h("section", { className: "fs-group" },
|
|
158
|
-
title ? h("h3", { className: "fs-group-head" }, h("span", null, title), aside ?? null) : null, ...kids);
|
|
158
|
+
title ? h("h3", { className: "fs-group-head", title: tip }, h("span", null, title), aside ?? null) : null, ...kids);
|
|
159
159
|
}
|
|
160
160
|
const Input = (props) => h("input", { className: "fs-input", spellCheck: false, autoComplete: "off", ...props, className: cx("fs-input", props.className) });
|
|
161
161
|
const Num = (props) => h("input", { type: "number", inputMode: "numeric", ...props, className: "fs-input num" });
|
|
@@ -359,8 +359,9 @@ window.__ModuleLoader__.load({
|
|
|
359
359
|
const [busy, setBusy] = react.useState(false);
|
|
360
360
|
const [form, setForm, patch] = useForm(data, () => ({
|
|
361
361
|
enabled: qq.enabled === true, wsUrl: qq.wsUrl ?? "ws://127.0.0.1:3001", accessToken: "", masterQqUin: data.masterQqUin ?? "",
|
|
362
|
-
groupAllowList: (qq.groupAllowList ?? []).join(", "), allGroups: qq.allGroups === true, followUpCount: qq.followUpCount ?? 3, followUpSeconds: qq.followUpSeconds ?? 120,
|
|
363
|
-
callNames: (qq.callNames ?? []).join(", "), ij: { ...(qq.interject ?? {}), chance: Math.round((qq.interject?.chance ?? 0.05) * 100), keywords: (qq.interject?.keywords ?? []).join(", ") }
|
|
362
|
+
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(", ") },
|
|
364
|
+
stickersPerDay: qq.stickers?.perDay ?? 10
|
|
364
365
|
}));
|
|
365
366
|
if (!data || !form) return null;
|
|
366
367
|
|
|
@@ -370,8 +371,8 @@ window.__ModuleLoader__.load({
|
|
|
370
371
|
const res = await api("POST", "/channels/qq-config", {
|
|
371
372
|
enabled: next.enabled, wsUrl: next.wsUrl, ...(next.accessToken !== "" ? { accessToken: next.accessToken } : {}),
|
|
372
373
|
masterQqUin: next.masterQqUin, groupAllowList: split(next.groupAllowList), allGroups: next.allGroups === true,
|
|
373
|
-
followUpCount: Number(next.followUpCount), followUpSeconds: Number(next.followUpSeconds),
|
|
374
|
-
interject: { enabled: next.ij.enabled === true, chance: Number(next.ij.chance) / 100, cooldownMinutes: Number(next.ij.cooldownMinutes), maxPerHour: Number(next.ij.maxPerHour), keywords: split(next.ij.keywords)
|
|
374
|
+
followUpCount: Number(next.followUpCount), followUpSeconds: Number(next.followUpSeconds), batchMs: Number(next.batchMs), callNames: split(next.callNames),
|
|
375
|
+
interject: { enabled: next.ij.enabled === true, chance: Number(next.ij.chance) / 100, cooldownMinutes: Number(next.ij.cooldownMinutes), maxPerHour: Number(next.ij.maxPerHour), keywords: split(next.ij.keywords) }
|
|
375
376
|
});
|
|
376
377
|
if (res?.ok) { if (next.accessToken !== "") setForm({ ...next, accessToken: "" }); refresh(); } else notify(res?.error ?? "保存失败");
|
|
377
378
|
}
|
|
@@ -403,20 +404,17 @@ window.__ModuleLoader__.load({
|
|
|
403
404
|
form.enabled ? Row({ title: "点名后接着听" },
|
|
404
405
|
Num({ min: 0, max: 20, value: form.followUpCount, "aria-label": "条数", onChange: (e) => patch({ followUpCount: e.target.value }), onBlur: () => save() }), Muted("条"),
|
|
405
406
|
Num({ min: 0, max: 1800, value: form.followUpSeconds, "aria-label": "秒数", onChange: (e) => patch({ followUpSeconds: e.target.value }), onBlur: () => save() }), Muted("秒")) : null,
|
|
406
|
-
form.enabled ? Row({ title: "聊完自己收尾", desc: "她觉得这轮聊完了就不接着听,群里插话也压下去" },
|
|
407
|
-
h(Switch, { label: "聊完自己收尾", checked: form.followUpSmart === true, onChange: (v) => save({ followUpSmart: v }) })) : null,
|
|
408
407
|
form.enabled ? Row({ title: "连着发几条时", desc: "你连着发消息,她等你停手再一起回;0 = 每条都回" },
|
|
409
408
|
Num({ min: 0, max: 15000, step: 500, value: form.batchMs, "aria-label": "等待毫秒", onChange: (e) => patch({ batchMs: e.target.value }), onBlur: () => save({ batchMs: form.batchMs === "" ? 3000 : Number(form.batchMs) }) }), Muted("毫秒")) : null,
|
|
410
409
|
form.enabled ? Row({ title: "叫这些名字也算点名" }, field("callNames", { placeholder: "人格卡名字" })) : null,
|
|
411
410
|
form.enabled ? Row({ title: "群里主动插话" }, h(Switch, { label: "群里主动插话", checked: ij.enabled === true, onChange: (v) => save({ ij: { ...ij, enabled: v } }) })) : null,
|
|
412
411
|
form.enabled && ij.enabled ? Row({ title: "插话概率" }, ijNum("chance", "插话概率", 0, 100), Muted("%"), ijNum("maxPerHour", "每小时最多", 0, 30), Muted("次/小时")) : null,
|
|
413
412
|
form.enabled && ij.enabled ? Row({ title: "说完话后冷却" }, ijNum("cooldownMinutes", "冷却", 0, 1440), Muted("分钟")) : null,
|
|
414
|
-
form.enabled && ij.enabled ? Row({ title: "主人在聊时不插话", desc: "主人刚跟她说过话,她在群里就少插几句" },
|
|
415
|
-
h(Switch, { label: "主人在聊时不插话", checked: ij.masterQuiet !== false, onChange: (v) => save({ ij: { ...ij, masterQuiet: v } }) }),
|
|
416
|
-
ij.masterQuiet !== false ? ijNum("masterQuietMinutes", "分钟", 0, 60) : null,
|
|
417
|
-
ij.masterQuiet !== false ? Muted("分钟内") : null) : null,
|
|
418
413
|
form.enabled && ij.enabled ? Row({ title: "感兴趣的话题" }, Input({ value: ij.keywords ?? "", placeholder: "猫, 原神, 考研", onChange: (e) => patch({ ij: { ...ij, keywords: e.target.value } }), onBlur: () => save() })) : null,
|
|
419
|
-
form.enabled ? Row({ title: "收藏表情包", desc: qq.stickers?.collect !== false ? undefined : "看到喜欢的表情会存下来,之后自己用" },
|
|
414
|
+
form.enabled ? Row({ title: "收藏表情包", desc: qq.stickers?.collect !== false ? undefined : "看到喜欢的表情会存下来,之后自己用" },
|
|
415
|
+
h(Switch, { label: "收藏表情包", checked: qq.stickers?.collect !== false, onChange: (v) => run("/stickers/config", { collect: v }, v ? "会收藏喜欢的表情了" : "不再收藏表情", "保存失败") }),
|
|
416
|
+
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 }, "每天的上限改了", "保存失败"); } }),
|
|
417
|
+
Muted("张/天")) : null,
|
|
420
418
|
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,
|
|
421
419
|
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,
|
|
422
420
|
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),
|
|
@@ -428,29 +426,7 @@ window.__ModuleLoader__.load({
|
|
|
428
426
|
elsewhere > 0 ? Row({ title: `${elsewhere} 个会话在别的工作区` }, Button({ disabled: busy, onClick: () => run("/sessions/migrate", {}, (r) => `已迁移 ${r.migrated} 个${r.failed?.length ? `,${r.failed.length} 个失败` : ""}`, "迁移失败") }, "迁过来")) : null,
|
|
429
427
|
chats.length > 0 ? h("div", { className: "fs-list" }, chats.slice(0, 30).map((c) => Item({ key: c.sessionId, text: c.title || c.sessionId, meta: [
|
|
430
428
|
c.paused ? Tag("暂停中", "warning", { key: "p" }) : null, h("span", { key: "c" }, c.channelId),
|
|
431
|
-
Button({ key: "b", variant: "ghost", disabled: busy, onClick: () => pause(c) }, c.paused ? "恢复" : "暂停")] }))) : null)
|
|
432
|
-
h(DiagnoseGroup, { notify }));
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// 诊断:只读一键自检,六项结果绿 / 红;失败项自己带原因,不抛错
|
|
436
|
-
function DiagnoseGroup({ notify }) {
|
|
437
|
-
const [busy, setBusy] = react.useState(false);
|
|
438
|
-
const [data, setData] = react.useState(null);
|
|
439
|
-
async function run() {
|
|
440
|
-
setBusy(true);
|
|
441
|
-
const res = await api("GET", "/diagnose");
|
|
442
|
-
setBusy(false);
|
|
443
|
-
if (res?.items) setData(res); else notify(res?.error ?? "自检没跑成");
|
|
444
|
-
}
|
|
445
|
-
const items = data?.items ?? [];
|
|
446
|
-
const bad = items.filter((x) => x.ok !== true).length;
|
|
447
|
-
return Group({ title: "诊断", aside: items.length > 0 ? Tag(bad === 0 ? "都正常" : `${bad} 项要处理`, bad === 0 ? "success" : "warning") : null },
|
|
448
|
-
Row({ title: "一键自检", desc: "版本 / 通道 / QQ 登录 / 记忆条数 / 今日次数 / 补丁,只读不改任何东西" },
|
|
449
|
-
Button({ onClick: run, disabled: busy }, busy ? "检查中…" : "开始检查")),
|
|
450
|
-
items.length > 0 ? h("div", { className: "fs-list" }, items.map((it) => h("div", { className: "fs-item", key: it.key },
|
|
451
|
-
h("span", { className: "fs-dot", "data-state": it.ok === true ? "done" : "error", "aria-hidden": true }),
|
|
452
|
-
h("span", { className: cx("fs-item-main", it.ok === true && "fs-muted") }, it.label),
|
|
453
|
-
h("span", { className: "fs-item-meta", title: it.hint ?? undefined }, it.detail)))) : null);
|
|
429
|
+
Button({ key: "b", variant: "ghost", disabled: busy, onClick: () => pause(c) }, c.paused ? "恢复" : "暂停")] }))) : null));
|
|
454
430
|
}
|
|
455
431
|
|
|
456
432
|
// ── B站(登录 / 私信 / 自己刷视频) ──
|
|
@@ -776,22 +752,33 @@ window.__ModuleLoader__.load({
|
|
|
776
752
|
const label = TABS.find(([v]) => v === tab)?.[1] ?? "";
|
|
777
753
|
return h("div", { className: "fs-host" }, h("div", { className: cx("fs", navOpen ? null : "nav-collapsed", detail ? "has-detail" : null) },
|
|
778
754
|
css === "" ? null : h("style", { "data-plugin-css": `${PLUGIN_ID}/panel.css` }, css),
|
|
779
|
-
h("aside", { className: "fs-side" },
|
|
755
|
+
h("aside", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
|
|
780
756
|
h("div", { className: "fs-side-head" },
|
|
781
757
|
h("span", { className: "fs-tab-label fs-brand" }, "鱼"),
|
|
782
758
|
IconButton("sidebar", navOpen ? "收起侧栏" : "展开侧栏", () => setNavOpen(!navOpen), { "aria-expanded": navOpen })),
|
|
783
759
|
h(Tabs, { items: TABS, value: tab, onChange: setTab }),
|
|
784
|
-
h("div", { className: "fs-side-foot" }, h(Boundary, { name: "版本" }, h(UpdateBar, { notify, compact: true, onOpen: () => setDetail(detail === "version" ? null : "version") })))),
|
|
760
|
+
h("div", { className: "fs-side-foot" }, h(Boundary, { name: "版本" }, h(UpdateBar, { notify, compact: true, onOpen: () => setDetail(detail === "version" ? null : "version") }))))),
|
|
785
761
|
h("main", { className: "fs-main" },
|
|
786
762
|
h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label)),
|
|
787
763
|
h(Boundary, { name: "引导" }, h(Onboarding, { onGo: setTab, notify })),
|
|
788
764
|
h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${tab}`, key: tab }, h(Boundary, { name: "这一页" }, pane))),
|
|
789
|
-
detail === "version" ? h("aside", { className: "fs-detail", "aria-label": "版本" },
|
|
765
|
+
detail === "version" ? h("aside", { className: "fs-detail", "aria-label": "版本" }, h("div", { className: "fs-detail-inner" },
|
|
790
766
|
h("div", { className: "fs-detail-head" }, h("span", null, "版本"), IconButton("close", "关闭", () => setDetail(null))),
|
|
791
|
-
h(Boundary, { name: "版本" }, h(UpdateBar, { notify }))) : null,
|
|
767
|
+
h(Boundary, { name: "版本" }, h(UpdateBar, { notify })))) : null,
|
|
792
768
|
toast === null ? null : h("div", { className: "fs-toast", role: "status", key: toast.key }, toast.text)));
|
|
793
769
|
}
|
|
794
770
|
|
|
771
|
+
// 同一组件可在设置弹窗或宿主主面板内渲染;只改变承载容器。
|
|
772
|
+
function FishSoulPage() {
|
|
773
|
+
return h("div", { className: "fs-page-shell" },
|
|
774
|
+
h("div", { className: "fs-page-scroll" }, h(FishSoulSection)));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function FishSoulPanelIcon({ size = 16 } = {}) {
|
|
778
|
+
return 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 },
|
|
779
|
+
h("path", { d: "M3 12c3-5 9-7 14-3l4-3v12l-4-3c-5 4-11 2-14-3Z M14 11h.01 M7 12h.01" }));
|
|
780
|
+
}
|
|
781
|
+
|
|
795
782
|
function apply(ctx) {
|
|
796
783
|
ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
797
784
|
name: "settings.section",
|
|
@@ -799,6 +786,18 @@ window.__ModuleLoader__.load({
|
|
|
799
786
|
order: 60,
|
|
800
787
|
label: () => "鱼 · 记忆与自走"
|
|
801
788
|
}, FishSoulSection)), `${PLUGIN_ID}: panel`);
|
|
789
|
+
// main 先于入口注册;主面板不存在的旧宿主只保留设置页。
|
|
790
|
+
ctx.effect(() => ctx.slots.inject("main", function* () {
|
|
791
|
+
yield ctx.slots.register({ name: "main", key: PLUGIN_ID }, FishSoulPage);
|
|
792
|
+
yield ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
|
|
793
|
+
name: "sidebar.panellist", id: PLUGIN_ID, order: 2, label: () => "鱼"
|
|
794
|
+
}, FishSoulPanelIcon));
|
|
795
|
+
// 卸载当前页前回到会话;layout 为可选服务,不阻塞旧宿主。
|
|
796
|
+
yield () => {
|
|
797
|
+
const layout = typeof ctx.get === "function" ? ctx.get("layout") : null;
|
|
798
|
+
if (layout?.panelInfo?.getSnapshot()?.activePanelId === PLUGIN_ID) layout.selectPanel(null);
|
|
799
|
+
};
|
|
800
|
+
}), `${PLUGIN_ID}: main panel`);
|
|
802
801
|
}
|
|
803
802
|
|
|
804
803
|
exports.apply = apply;
|
package/lib/client.models.js
CHANGED
|
@@ -135,7 +135,7 @@ window.__ModuleLoader__.load({
|
|
|
135
135
|
h(Secret, { label: "API Key", value: key, placeholder: w.hasApiKey ? w.apiKeyMasked : "粘贴 key", onChange: (e) => setKey(e.target.value), onBlur: () => { if (key.trim() !== "") save({ apiKey: key.trim() }); } })) : null,
|
|
136
136
|
own && cur.needsBaseURL ? Row({ title: "SearXNG 地址" },
|
|
137
137
|
Input({ value: baseURL, placeholder: "http://127.0.0.1:8080", "aria-label": "SearXNG 地址", onChange: (e) => setBaseURL(e.target.value), onBlur: () => { if (baseURL !== (w.baseURL ?? "")) save({ baseURL }); } })) : null,
|
|
138
|
-
own && w.available ? Row({ title: "测一下", meta: result ? `搜到 ${result.count} 条 · ${(result.latencyMs / 1000).toFixed(1)} 秒${result.results?.[0] ? ` · 第一条:${result.results[0].title}` : ""}` : "搜一次「今天有什么新闻」,不算今天的次数" },
|
|
138
|
+
own && w.available ? Row({ title: "测一下", meta: result ? `搜到 ${result.count} 条 · ${(result.latencyMs / 1000).toFixed(1)} 秒${result.results?.[0] ? ` · 第一条:${result.results[0].title}` : ""}` : undefined, desc: "搜一次「今天有什么新闻」,不算今天的次数" },
|
|
139
139
|
Button({ disabled: busy, onClick: test }, busy ? "搜索中" : "测一下")) : null,
|
|
140
140
|
w.enabled ? Row({ title: "每天最多" }, Num({ min: 0, max: 500, value: perDay, "aria-label": "每天最多", onChange: (e) => setPerDay(e.target.value), onBlur: () => save({}) }), Muted("次")) : null);
|
|
141
141
|
}
|
package/lib/client.persona.js
CHANGED
|
@@ -64,8 +64,7 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
return h("div", { className: "fs-persona" },
|
|
65
65
|
h("div", { className: "fs-persona-head" },
|
|
66
66
|
h("div", { className: "fs-persona-title" },
|
|
67
|
-
h("b", null, t.title), current ? Tag("在用", "info") : null,
|
|
68
|
-
h("span", { className: "fs-row-desc" }, t.tagline)),
|
|
67
|
+
h("b", null, t.title), current ? Tag("在用", "info") : null),
|
|
69
68
|
h("div", { className: "fs-ctl" },
|
|
70
69
|
Button({ variant: "ghost", onClick: onOpen, "aria-expanded": open }, open ? "收起" : "看看"),
|
|
71
70
|
confirm
|
|
@@ -104,7 +103,7 @@ window.__ModuleLoader__.load({
|
|
|
104
103
|
}) };
|
|
105
104
|
return { spec: "chara_card_v2", spec_version: "2.0", data };
|
|
106
105
|
}
|
|
107
|
-
return Group({ title: "编辑人格",
|
|
106
|
+
return Group({ title: "编辑人格", tip: "记忆和关系不变,换的是她是谁、怎么说话" },
|
|
108
107
|
...FIELDS.map((x) => Row({ key: x.key, title: x.title, desc: x.desc, stack: Boolean(x.area || x.stack) },
|
|
109
108
|
x.area ? Area({ rows: x.area, value: form[x.key], maxLength: x.max, "aria-label": x.title, onChange: (e) => set(x.key, e.target.value) })
|
|
110
109
|
: Input({ value: form[x.key], maxLength: x.max, "aria-label": x.title, onChange: (e) => set(x.key, e.target.value) }))),
|
|
@@ -266,9 +265,7 @@ window.__ModuleLoader__.load({
|
|
|
266
265
|
Group({ title: "现在的人格", aside: data.canUndo ? Button({ variant: "ghost", disabled: busy, onClick: undo }, "撤销上次更换") : null },
|
|
267
266
|
Row({ title: "名字", desc: cur.source === "persona.png" ? "从 persona.png 读的;在这里改了会另存成 persona.json" : undefined },
|
|
268
267
|
h("b", null, cur.name), Tag(SOURCE[cur.source] ?? cur.source, cur.source === "builtin" ? "outline" : undefined)),
|
|
269
|
-
cur.source
|
|
270
|
-
? Row({ title: "设定" }, Muted("只有一句话,挑个模板或者导入自己的角色卡会像真人很多"))
|
|
271
|
-
: Row({ title: "一句话人设", stack: true }, h("p", { className: "fs-persona-desc" }, cur.anchor || "没写(会用性格那一栏顶上)")),
|
|
268
|
+
cur.source !== "builtin" ? Row({ title: "一句话人设", stack: true }, h("p", { className: "fs-persona-desc" }, cur.anchor || "没写(会用性格那一栏顶上)")) : null,
|
|
272
269
|
cur.source !== "builtin" && (cur.perspective ?? []).length > 0 ? Row({ title: "看事情的方式", stack: true }, h("p", { className: "fs-persona-desc" }, cur.perspective.join(";"))) : null,
|
|
273
270
|
cur.source !== "builtin" ? Row({ title: "作息", meta: cur.routine ? undefined : "没写:排日程时只能按设定猜", stack: Boolean(cur.routine) }, cur.routine ? h("p", { className: "fs-persona-desc" }, cur.routine) : null) : null,
|
|
274
271
|
Row({ title: "改人格" },
|
|
@@ -278,7 +275,7 @@ window.__ModuleLoader__.load({
|
|
|
278
275
|
h("input", { ref: fileRef, type: "file", accept: ".json,.png,application/json,image/png", hidden: true, onChange: pickFile })),
|
|
279
276
|
Row({ title: "正在聊的也马上换", desc: freshOn ? "下一条消息起开新会话,旧聊天的口吻不带过来;记忆和关系都还在" : "沿用旧会话:新人格下一轮生效,但口吻可能要几轮才转过来" },
|
|
280
277
|
h(Switch, { label: "正在聊的也马上换", checked: freshOn, onChange: toggleFresh }))),
|
|
281
|
-
Group({ title: "人格模板"
|
|
278
|
+
Group({ title: "人格模板" },
|
|
282
279
|
...(data.templates ?? []).map((t) => h(Template, {
|
|
283
280
|
key: t.id, t, h, ui, open: open === t.id, current: cur.template === t.id && cur.source === "persona.json",
|
|
284
281
|
onOpen: () => setOpen(open === t.id ? null : t.id),
|
package/lib/client.soul.js
CHANGED
|
@@ -119,7 +119,7 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
p.stageLocked && p.canonicalId !== "master" ? Button({ variant: "ghost", disabled: busy, onClick: () => save({ stageLocked: false }, "关系交还给好感了") }, "改回自动") : null,
|
|
120
120
|
Select({ value: p.relationStage, disabled: busy, "aria-label": "关系", onChange: (e) => save({ relationStage: e.target.value }, `关系改成「${stageLabel(e.target.value)}」`) },
|
|
121
121
|
...STAGES.map(([k, v]) => h("option", { key: k, value: k }, v)))),
|
|
122
|
-
Row({ title: "好感", meta: mood ? `她现在有点${mood.label}${mood.reason ? `(${mood.reason})` : ""}` :
|
|
122
|
+
Row({ title: "好感", meta: mood ? `她现在有点${mood.label}${mood.reason ? `(${mood.reason})` : ""}` : undefined },
|
|
123
123
|
h("div", { className: "fs-track", role: "meter", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": p.affinity ?? 0, "aria-label": "好感" },
|
|
124
124
|
h("div", { className: "fs-fill", style: { width: `${Math.max(0, Math.min(100, p.affinity ?? 0))}%` } })),
|
|
125
125
|
Muted(String(Math.round(p.affinity ?? 0)), { className: "fs-unit num" })),
|
|
@@ -127,7 +127,7 @@ window.__ModuleLoader__.load({
|
|
|
127
127
|
lockedF.has(f.id) ? Tag("手写", "info", { title: "主人写的,Dream 不改;清空就交还" }) : null,
|
|
128
128
|
Input({ value: form.fields[f.id] ?? "", placeholder: "还不知道", "aria-label": f.label, disabled: busy,
|
|
129
129
|
onChange: (e) => setForm({ ...form, fields: { ...form.fields, [f.id]: e.target.value } }), onBlur: () => saveField(f.id) })))) : null,
|
|
130
|
-
form && !legacy ? Row({ title: "印象", meta: prof ? `${prof.by === "user" ? "改过" : "Dream 整理"} · ${fmtDay(prof.updatedAt)}` :
|
|
130
|
+
form && !legacy ? Row({ title: "印象", meta: prof ? `${prof.by === "user" ? "改过" : "Dream 整理"} · ${fmtDay(prof.updatedAt)}` : undefined },
|
|
131
131
|
Button({ variant: "ghost", disabled: busy, onClick: dream }, "现在整理一次")) : null,
|
|
132
132
|
form && legacy ? Row({ title: "印象", stack: true },
|
|
133
133
|
Area({ value: form.profile, rows: 3, placeholder: "还没有印象:聊得多了 Dream 会写,也可以自己写", "aria-label": "印象", onChange: (e) => setForm({ ...form, profile: e.target.value }) }),
|
|
@@ -424,23 +424,84 @@ window.__ModuleLoader__.load({
|
|
|
424
424
|
recalled: ["撤回", "outline"], command: ["命令", "info"], proactive: ["主动", "info"], digest: ["记下", "success"],
|
|
425
425
|
filtered: ["改写", "outline"], diary: ["日记", "success"], browse: ["刷视频", "outline"], share: ["分享", "success"], post: ["发动态", "success"], comment: ["评论区", "info"], mood: ["心情", "info"], poke: ["戳回", "info"], react: ["回应", "info"]
|
|
426
426
|
};
|
|
427
|
-
/**
|
|
427
|
+
/** 读本地图片 → base64(去掉 data: 前缀);读不出来给 null。 */
|
|
428
|
+
function readBase64(file) {
|
|
429
|
+
return new Promise((resolve) => {
|
|
430
|
+
const reader = new FileReader();
|
|
431
|
+
reader.onload = () => { const s = String(reader.result ?? ""); resolve(s.includes(",") ? s.slice(s.indexOf(",") + 1) : null); };
|
|
432
|
+
reader.onerror = () => resolve(null);
|
|
433
|
+
reader.readAsDataURL(file);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 一张表情:名字能直接改(失焦 / 回车保存,Esc 撤销),也能删 */
|
|
438
|
+
function StickerCard({ x, api, apiBase, notify, refresh, relTime, ui, onRemove }) {
|
|
439
|
+
const { h, Input, IconButton } = ui;
|
|
440
|
+
const base = x.file.replace(/\.[a-z]+$/i, "");
|
|
441
|
+
const [name, setName] = react.useState(base);
|
|
442
|
+
const [busy, setBusy] = react.useState(false);
|
|
443
|
+
const saving = react.useRef(false);
|
|
444
|
+
const done = react.useRef(false);
|
|
445
|
+
async function save() {
|
|
446
|
+
if (saving.current || done.current) return;
|
|
447
|
+
const next = name.trim();
|
|
448
|
+
if (next === "" || next === base) { setName(base); return; }
|
|
449
|
+
saving.current = true; setBusy(true);
|
|
450
|
+
const r = await api("POST", "/stickers/rename", { file: x.file, workspacePath: x.workspacePath, name: next });
|
|
451
|
+
saving.current = false; setBusy(false);
|
|
452
|
+
if (r?.ok) { done.current = true; notify("改好了"); refresh(); }
|
|
453
|
+
else { notify(r?.error ?? "改名失败"); setName(base); }
|
|
454
|
+
}
|
|
455
|
+
const tip = [x.file, x.tracked ? (x.from === "主人" ? "主人导入的" : x.from ? `从 ${x.from} 那里` : "") : "主人自己放进目录的", x.summary, relTime(x.at, Date.now())].filter(Boolean).join("\n");
|
|
456
|
+
return h("figure", { className: "fs-sticker", title: tip },
|
|
457
|
+
h("img", { alt: base, loading: "lazy", src: `${apiBase}/stickers/file?file=${encodeURIComponent(x.file)}&ws=${encodeURIComponent(x.workspacePath)}` }),
|
|
458
|
+
h("figcaption", null,
|
|
459
|
+
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); } })),
|
|
460
|
+
IconButton("close", "删除", () => onRemove(x)));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 表情包:她收藏的 + 主人自己放进 _stickers 的(动态页底部) */
|
|
428
464
|
function StickerShelf({ api, apiBase, notify, relTime, ui }) {
|
|
429
|
-
const { h, Group, Tag, Empty,
|
|
465
|
+
const { h, Group, Tag, Empty, Row, Button, Muted } = ui;
|
|
430
466
|
const [data, load] = usePoll(api, "/stickers/state", 15000);
|
|
467
|
+
const [busy, setBusy] = react.useState(false);
|
|
468
|
+
const picker = react.useRef(null);
|
|
431
469
|
if (!data || data.ok === false) return null;
|
|
432
|
-
|
|
433
|
-
|
|
470
|
+
// 最近的排前面:主人刚放进目录的图不会被收藏记录挤出前 60 张
|
|
471
|
+
const items = (data.items ?? []).filter((x) => x.exists).slice().sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
|
|
434
472
|
async function remove(x) {
|
|
435
473
|
const r = await api("POST", "/stickers/delete", { file: x.file, workspacePath: x.workspacePath });
|
|
436
474
|
if (r?.ok) { notify("删掉了"); load(); } else notify(r?.error ?? "删除失败");
|
|
437
475
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
476
|
+
async function importFiles(files) {
|
|
477
|
+
const list = Array.from(files ?? []);
|
|
478
|
+
if (list.length === 0) return;
|
|
479
|
+
setBusy(true);
|
|
480
|
+
let doneCount = 0; let skipped = 0; let failed = 0; let full = false;
|
|
481
|
+
for (const f of list) {
|
|
482
|
+
if (f.size > 4 * 1024 * 1024) { skipped++; continue; }
|
|
483
|
+
const b64 = await readBase64(f);
|
|
484
|
+
if (b64 === null) { failed++; continue; }
|
|
485
|
+
const r = await api("POST", "/stickers/import", { name: f.name.replace(/\.[a-z]+$/i, ""), mediaType: f.type, base64: b64 });
|
|
486
|
+
if (r?.ok) doneCount++;
|
|
487
|
+
else if (r?.full === true) { failed++; full = true; break; }
|
|
488
|
+
else failed++;
|
|
489
|
+
}
|
|
490
|
+
setBusy(false);
|
|
491
|
+
const parts = [];
|
|
492
|
+
if (doneCount > 0) parts.push(`导入了 ${doneCount} 张`);
|
|
493
|
+
if (skipped > 0) parts.push(`${skipped} 张超过 4MB 跳过`);
|
|
494
|
+
if (failed > 0) parts.push(`${failed} 张没导进去`);
|
|
495
|
+
if (full) parts.push("_stickers 满了");
|
|
496
|
+
notify(parts.length > 0 ? parts.join(",") : "没有导入");
|
|
497
|
+
load();
|
|
498
|
+
}
|
|
499
|
+
return Group({ title: "表情包", aside: items.length ? Tag(String(items.length), "outline") : null },
|
|
500
|
+
Row({ title: "导入本地图片" },
|
|
501
|
+
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); } }),
|
|
502
|
+
Button({ variant: "ghost", disabled: busy, "aria-label": "导入本地图片", onClick: () => picker.current?.click() }, busy ? "导入中…" : "选图导入"),
|
|
503
|
+
Muted("不占每天收藏的额度")),
|
|
504
|
+
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
505
|
}
|
|
445
506
|
|
|
446
507
|
/** 一个群 / 私聊学到的说法 */
|
|
@@ -468,7 +529,7 @@ window.__ModuleLoader__.load({
|
|
|
468
529
|
const add = () => { if (term.trim() && meaning.trim()) { edit({ add: { term, meaning } }, "加上了"); setTerm(""); setMeaning(""); } };
|
|
469
530
|
const addWay = () => { if (when.trim() && how.trim()) { edit({ addExpression: { situation: when, style: how } }, "加上了"); setWhen(""); setHow(""); } };
|
|
470
531
|
const ways = g.expressions ?? [];
|
|
471
|
-
return Group({ title: isGlobal ? "所有聊天都能用的说法(导入的)" : `${isGroup ? "群里的说法" : "私聊学到的"} · ${g.title || g.channelId}`,
|
|
532
|
+
return Group({ title: isGlobal ? "所有聊天都能用的说法(导入的)" : `${isGroup ? "群里的说法" : "私聊学到的"} · ${g.title || g.channelId}`, tip: isGlobal ? "排在每个聊天自己学的后面" : undefined, aside: isGlobal ? null : h("span", { className: "fs-group-aside" },
|
|
472
533
|
Muted(`${g.learnedAt ? `${relTime(g.learnedAt, Date.now())}学的` : "还没学过"} · 攒了 ${g.lines} 句`),
|
|
473
534
|
Button({ variant: "ghost", disabled: busy, onClick: learn }, busy ? "学习中" : "现在学一次")) },
|
|
474
535
|
isGlobal ? null : Row({ title: "说话风格", desc: g.styleByUser ? "你改过,不会被覆盖" : undefined },
|
package/lib/panel.css
CHANGED
|
@@ -1,34 +1,55 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/* 插件作用域的 Harness 风格令牌;尊重宿主明暗方案,不改写宿主主题。 */
|
|
2
|
+
:is(.fs,.fs-page-shell){
|
|
3
|
+
--bg-window:#0f1116;--bg-sidebar:#0b0d11;--bg-surface:#14171f;--bg-control:#0b0d12;
|
|
4
|
+
--border-hairline:rgba(255,255,255,.055);--border-subtle:rgba(255,255,255,.095);
|
|
5
|
+
--accent:#4d6bfe;--accent-hover:#627cff;--fg-primary:#eef0f5;--fg-secondary:#b4bbc9;--fg-tertiary:#929bac;
|
|
6
|
+
--fg-on-accent:#fff;--bg-hover:rgba(255,255,255,.055);--bg-active:rgba(255,255,255,.085);
|
|
7
|
+
--status-success:var(--dsw-alias-state-success-primary,#48c992);--status-warn:var(--dsw-alias-state-warn-primary,#f6b95f);
|
|
8
|
+
--status-error:var(--dsw-alias-state-error-primary,#f27379);--status-idle:var(--fg-tertiary);
|
|
9
|
+
--fs-line:var(--border-hairline);--fs-line-strong:var(--border-subtle);
|
|
10
|
+
--fs-mono:"JetBrains Mono",SFMono-Regular,ui-monospace,Menlo,Consolas,"Liberation Mono",monospace;
|
|
11
|
+
color-scheme:dark
|
|
12
|
+
}
|
|
13
|
+
/* 宿主完成主题解析后,浅色模式覆盖;没有宿主主题时默认为深色。 */
|
|
14
|
+
html[data-ds-theme-source] body:not([data-ds-dark-theme]) :is(.fs,.fs-page-shell){
|
|
15
|
+
--bg-window:#f7f8fa;--bg-sidebar:#f3f4f6;--bg-surface:#fff;--bg-control:#f8f9fa;
|
|
16
|
+
--border-hairline:rgba(15,17,22,.08);--border-subtle:rgba(15,17,22,.14);
|
|
17
|
+
--accent-hover:#3b57e7;--fg-primary:#171a21;--fg-secondary:#555d6a;--fg-tertiary:#6d7582;
|
|
18
|
+
--bg-hover:rgba(15,17,22,.045);--bg-active:rgba(15,17,22,.075);color-scheme:light
|
|
19
|
+
}
|
|
20
|
+
.fs{--fs-side:176px;--fs-detail:300px;
|
|
21
|
+
position:relative;display:grid;grid-template-columns:var(--fs-side) minmax(0,1fr);align-items:start;width:100%;max-width:1180px;min-height:480px;border:1px solid var(--fs-line);border-radius:12px;background:var(--bg-surface);color:var(--fg-primary);font-size:13px;line-height:20px;font-variant-numeric:tabular-nums}
|
|
7
22
|
.fs.has-detail{grid-template-columns:var(--fs-side) minmax(0,1fr) var(--fs-detail)}
|
|
8
23
|
.fs.nav-collapsed{--fs-side:48px}
|
|
9
24
|
.fs *,.fs *::before,.fs *::after{box-sizing:border-box}
|
|
10
25
|
.fs :where(h2,h3,p,pre,ul,figure){margin:0;padding:0}
|
|
11
26
|
.fs :where(button,input,select,textarea){font:inherit;color:inherit}
|
|
12
|
-
.fs :where(button,a,input,select,textarea,summary):focus-visible{outline:1px solid var(--
|
|
27
|
+
.fs :where(button,a,input,select,textarea,summary):focus-visible{outline:1px solid var(--accent);outline-offset:1px}
|
|
13
28
|
.fs .num,.fs-unit.num{font-family:var(--fs-mono);font-size:12px;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
|
14
|
-
.fs-muted{color:var(--
|
|
29
|
+
.fs-muted{color:var(--fg-tertiary)}
|
|
15
30
|
|
|
31
|
+
/* 主面板占满宿主高度,内容在自己的滚动区内滚动;设置页仍由宿主滚动。 */
|
|
32
|
+
.fs-page-shell{display:flex;flex-direction:column;width:100%;height:100%;min-width:0;min-height:0;padding-top:max(24px,var(--dsh-frame-top-clearance,0px));box-sizing:border-box;background:var(--bg-window)}
|
|
33
|
+
.fs-page-scroll{flex:1;min-height:0;min-width:0;overflow-y:auto;overflow-x:hidden;padding:0 clamp(16px,3vw,40px) 32px}
|
|
34
|
+
.fs-page-scroll>.fs-host{margin:0 auto;max-width:1180px}
|
|
16
35
|
.fs-host{container-type:inline-size;width:100%}
|
|
17
36
|
|
|
18
37
|
/* 侧栏 */
|
|
19
|
-
.fs-side{
|
|
38
|
+
.fs-side{min-width:0;align-self:stretch;padding:8px;border-right:1px solid var(--fs-line);background:var(--bg-sidebar);border-radius:12px 0 0 12px}
|
|
39
|
+
/* 常驻:侧栏内容、主标题条、详情面板随滚动吸顶 */
|
|
40
|
+
.fs-side-inner{position:sticky;top:8px;display:flex;flex-direction:column;gap:2px;min-height:min(464px,calc(100vh - 16px))}
|
|
20
41
|
.fs-side-head{display:flex;align-items:center;justify-content:space-between;height:28px;padding:0 0 0 8px;margin-bottom:6px}
|
|
21
42
|
.fs-brand{font-weight:600;font-size:13px}
|
|
22
43
|
.fs-nav{display:flex;flex-direction:column;gap:1px}
|
|
23
|
-
.fs-tab{display:flex;align-items:center;gap:8px;height:28px;padding:0 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--
|
|
24
|
-
.fs-tab-key{flex:none;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:1px solid var(--fs-line);border-radius:4px;font-size:11px;line-height:1;color:var(--
|
|
44
|
+
.fs-tab{display:flex;align-items:center;gap:8px;height:28px;padding:0 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--fg-secondary);font-size:13px;line-height:20px;text-align:left;white-space:nowrap;cursor:pointer;transition:background 120ms ease,color 120ms ease}
|
|
45
|
+
.fs-tab-key{flex:none;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:1px solid var(--fs-line);border-radius:4px;font-size:11px;line-height:1;color:var(--fg-tertiary)}
|
|
25
46
|
.fs-tab-label{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
26
|
-
.fs-tab[aria-selected=true]{border-color:var(--fs-line);background:var(--
|
|
27
|
-
.fs-tab[aria-selected=true] .fs-tab-key{border-color:var(--fs-line-strong);color:var(--
|
|
28
|
-
@media (hover:hover){.fs-tab:hover{background:var(--
|
|
47
|
+
.fs-tab[aria-selected=true]{border-color:var(--fs-line);background:var(--bg-control);color:var(--fg-primary);font-weight:500}
|
|
48
|
+
.fs-tab[aria-selected=true] .fs-tab-key{border-color:var(--fs-line-strong);color:var(--fg-primary)}
|
|
49
|
+
@media (hover:hover){.fs-tab:hover{background:var(--bg-hover);color:var(--fg-primary)}}
|
|
29
50
|
.fs-side-foot{margin-top:auto;padding-top:8px;border-top:1px solid var(--fs-line)}
|
|
30
|
-
.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(--
|
|
31
|
-
@media (hover:hover){.fs-ver:hover{background:var(--
|
|
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)}}
|
|
32
53
|
.fs.nav-collapsed .fs-side{padding:8px 6px}
|
|
33
54
|
.fs.nav-collapsed .fs-side-head{justify-content:center;padding:0}
|
|
34
55
|
.fs.nav-collapsed .fs-tab-label{display:none}
|
|
@@ -36,19 +57,20 @@
|
|
|
36
57
|
|
|
37
58
|
/* 主任务流 */
|
|
38
59
|
.fs-main{display:flex;flex-direction:column;gap:20px;min-width:0;width:100%;max-width:760px;justify-self:center;padding:14px 24px 24px}
|
|
39
|
-
.fs-main-head{display:flex;align-items:center;height:
|
|
60
|
+
.fs-main-head{position:sticky;top:0;z-index:3;display:flex;align-items:center;height:48px;margin-top:-14px;padding-top:14px;padding-bottom:6px;border-bottom:1px solid var(--fs-line);background:var(--bg-surface)}
|
|
40
61
|
.fs-main-title{font-size:14px;line-height:20px;font-weight:600}
|
|
41
62
|
.fs-pane{display:flex;flex-direction:column;gap:20px;min-width:0}
|
|
42
63
|
|
|
43
64
|
/* 详情面板 */
|
|
44
|
-
.fs-detail{
|
|
65
|
+
.fs-detail{display:block;min-width:0;align-self:stretch;padding:14px 16px;border-left:1px solid var(--fs-line);background:var(--bg-sidebar);border-radius:0 12px 12px 0}
|
|
66
|
+
.fs-detail-inner{position:sticky;top:14px;display:flex;flex-direction:column;gap:12px}
|
|
45
67
|
.fs-detail-head{display:flex;align-items:center;justify-content:space-between;height:28px;padding-bottom:10px;border-bottom:1px solid var(--fs-line);font-weight:600}
|
|
46
68
|
.fs-detail .fs-row{flex-wrap:wrap;gap:6px 12px}
|
|
47
69
|
.fs-detail .fs-row-title{max-width:none}
|
|
48
70
|
|
|
49
71
|
/* 分组与行:紧凑平铺,1px 低对比分隔 */
|
|
50
72
|
.fs-group{display:flex;flex-direction:column;min-width:0}
|
|
51
|
-
.fs-group-head{display:flex;align-items:center;gap:8px;height:24px;font-size:11px;font-weight:600;line-height:16px;letter-spacing:.02em;color:var(--
|
|
73
|
+
.fs-group-head{display:flex;align-items:center;gap:8px;height:24px;font-size:11px;font-weight:600;line-height:16px;letter-spacing:.02em;color:var(--fg-tertiary)}
|
|
52
74
|
.fs-group-head>:nth-child(2){margin-left:auto}
|
|
53
75
|
.fs-group-head .fs-state{font-size:12px;line-height:18px;font-weight:400}
|
|
54
76
|
.fs-row{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:44px;padding:7px 0;border-bottom:1px solid var(--fs-line)}
|
|
@@ -56,71 +78,72 @@
|
|
|
56
78
|
.fs-group-head+.fs-row,.fs-group-head+.fs-list,.fs-group-head+.fs-steps{border-top:1px solid var(--fs-line)}
|
|
57
79
|
.fs-row-title{flex:1 1 auto;min-width:0;max-width:50%;display:flex;flex-direction:column;gap:1px}
|
|
58
80
|
.fs-row-label.has-tip{cursor:help;text-decoration:underline dotted var(--fs-line-strong);text-underline-offset:4px;text-decoration-thickness:1px}
|
|
59
|
-
.fs-
|
|
60
|
-
.fs-row-
|
|
81
|
+
.fs-group-note-title{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px}
|
|
82
|
+
.fs-row-desc{font-size:12px;line-height:18px;color:var(--fg-tertiary)}
|
|
83
|
+
.fs-row-meta{font:11px/16px var(--fs-mono);font-variant-numeric:tabular-nums;color:var(--fg-tertiary);overflow-wrap:anywhere}
|
|
61
84
|
.fs-row-meta a{color:inherit}
|
|
62
|
-
.fs-ctl{flex:0 1 auto;min-width:0;display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:6px}
|
|
85
|
+
.fs-ctl{flex:0 1 auto;min-width:0;max-width:100%;display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:6px}
|
|
63
86
|
.fs-row.stack{flex-direction:column;align-items:stretch;gap:6px}
|
|
64
87
|
.fs-row.stack .fs-row-title{max-width:none}
|
|
65
88
|
.fs-row.stack .fs-ctl{justify-content:flex-start}
|
|
66
|
-
.fs-unit{font-size:12px;line-height:18px;color:var(--
|
|
89
|
+
.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}
|
|
67
90
|
|
|
68
91
|
/* 输入框:settings-form 的 input */
|
|
69
|
-
.fs-input{flex:0 1 240px;width:240px;min-width:0;height:28px;padding:0 10px;border:1px solid var(--
|
|
70
|
-
.fs-input::placeholder{color:var(--
|
|
71
|
-
.fs .fs-input:focus-visible{outline:none;border-color:var(--
|
|
72
|
-
.fs-input:disabled{color:var(--
|
|
92
|
+
.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
|
+
.fs-input::placeholder{color:var(--fg-tertiary);opacity:.7}
|
|
94
|
+
.fs .fs-input:focus-visible{outline:none;border-color:var(--accent)}
|
|
95
|
+
.fs-input:disabled{color:var(--fg-tertiary);cursor:default}
|
|
96
|
+
.fs-input[type=number],.fs-input[inputmode=numeric]{font-family:var(--fs-mono);font-variant-numeric:tabular-nums}
|
|
73
97
|
.fs-input.num{flex:none;width:64px;padding:0 8px;text-align:center;font-variant-numeric:tabular-nums}
|
|
74
98
|
.fs-input.wide,.fs-row.stack .fs-input:not(.num){flex:1 1 auto;width:100%}
|
|
75
99
|
textarea.fs-input{height:auto;min-height:56px;padding:5px 10px;resize:vertical;line-height:19px}
|
|
76
100
|
.fs-affix{position:relative;display:flex;flex:0 1 240px;width:240px;min-width:0}
|
|
77
|
-
.fs-affix .fs-input{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
78
101
|
.fs-affix .fs-input{flex:1 1 auto;width:100%;padding-right:36px}
|
|
79
102
|
.fs-affix>button{position:absolute;top:2px;right:2px}
|
|
80
103
|
select.fs-input{appearance:none;-webkit-appearance:none;padding-right:32px;cursor:pointer;text-overflow:ellipsis}
|
|
81
|
-
.fs-affix.select>svg{position:absolute;top:8px;right:12px;color:var(--
|
|
104
|
+
.fs-affix.select>svg{position:absolute;top:8px;right:12px;color:var(--fg-tertiary);pointer-events:none}
|
|
82
105
|
|
|
83
106
|
/* Switch */
|
|
84
|
-
.fs-switch{position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--
|
|
85
|
-
.fs-switch[aria-checked=true]{background:var(--
|
|
107
|
+
.fs-switch{position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--fs-line-strong);cursor:pointer}
|
|
108
|
+
.fs-switch[aria-checked=true]{background:var(--accent)}
|
|
86
109
|
.fs-switch:disabled{cursor:default;opacity:.5}
|
|
87
|
-
.fs-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--
|
|
88
|
-
.fs-switch[aria-checked=false] .fs-switch-thumb{background:var(--
|
|
110
|
+
.fs-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--fg-on-accent);transition:transform 120ms ease}
|
|
111
|
+
.fs-switch[aria-checked=false] .fs-switch-thumb{background:var(--fg-secondary)}
|
|
89
112
|
.fs-switch[aria-checked=true] .fs-switch-thumb{transform:translateX(16px)}
|
|
90
113
|
|
|
91
114
|
/* Button(sm):outline / primary / ghost / danger */
|
|
92
|
-
.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(--
|
|
93
|
-
.fs-btn.primary{box-shadow:none;background:var(--
|
|
94
|
-
.fs-btn.ghost{box-shadow:none;color:var(--
|
|
95
|
-
.fs-btn.danger{color:var(--
|
|
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}
|
|
116
|
+
.fs-btn.primary{box-shadow:none;background:var(--accent);color:var(--fg-on-accent)}
|
|
117
|
+
.fs-btn.ghost{box-shadow:none;color:var(--fg-secondary)}
|
|
118
|
+
.fs-btn.danger{color:var(--status-error)}
|
|
96
119
|
@media (hover:hover){
|
|
97
|
-
.fs-btn:hover:not(:disabled){background:var(--
|
|
98
|
-
.fs-btn.ghost:hover:not(:disabled){color:var(--
|
|
99
|
-
.fs-btn.primary:hover:not(:disabled){background:var(--
|
|
100
|
-
.fs-btn.danger:hover:not(:disabled){background:var(--
|
|
120
|
+
.fs-btn:hover:not(:disabled){background:var(--bg-hover)}
|
|
121
|
+
.fs-btn.ghost:hover:not(:disabled){color:var(--fg-primary)}
|
|
122
|
+
.fs-btn.primary:hover:not(:disabled){background:var(--accent-hover)}
|
|
123
|
+
.fs-btn.danger:hover:not(:disabled){background:var(--bg-hover)}
|
|
101
124
|
}
|
|
102
|
-
.fs-btn:active:not(:disabled){background:var(--
|
|
125
|
+
.fs-btn:active:not(:disabled){background:var(--bg-active)}
|
|
103
126
|
.fs-btn:disabled{opacity:.4;cursor:not-allowed}
|
|
104
|
-
.fs-icon-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--
|
|
105
|
-
@media (hover:hover){.fs-icon-btn:hover{background:var(--
|
|
127
|
+
.fs-icon-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--fg-tertiary);cursor:pointer}
|
|
128
|
+
@media (hover:hover){.fs-icon-btn:hover{background:var(--bg-hover);color:var(--fg-secondary)}}
|
|
106
129
|
|
|
107
130
|
/* SegmentedControl */
|
|
108
|
-
.fs-seg{position:relative;display:inline-grid;flex:none;grid-auto-flow:column;grid-auto-columns:1fr;gap:2px;padding:2px;border:1px solid var(--fs-line);border-radius:8px;background:var(--
|
|
109
|
-
.fs-seg-ind{position:absolute;top:2px;left:2px;width:calc((100% - 4px - 2px * (var(--n) - 1)) / var(--n));height:calc(100% - 4px);border-radius:6px;background:var(--
|
|
110
|
-
.fs-seg-btn{position:relative;z-index:1;height:22px;padding:0 10px;border:0;border-radius:6px;background:transparent;color:var(--
|
|
111
|
-
.fs-seg-btn[aria-checked=true]{color:var(--
|
|
112
|
-
@media (hover:hover){.fs-seg-btn:hover{color:var(--
|
|
131
|
+
.fs-seg{position:relative;display:inline-grid;flex:none;grid-auto-flow:column;grid-auto-columns:1fr;gap:2px;padding:2px;border:1px solid var(--fs-line);border-radius:8px;background:var(--bg-sidebar)}
|
|
132
|
+
.fs-seg-ind{position:absolute;top:2px;left:2px;width:calc((100% - 4px - 2px * (var(--n) - 1)) / var(--n));height:calc(100% - 4px);border-radius:6px;background:var(--bg-surface);box-shadow:inset 0 0 0 1px var(--fs-line);transform:translateX(calc(var(--i) * (100% + 2px)));transition:transform 160ms ease;pointer-events:none}
|
|
133
|
+
.fs-seg-btn{position:relative;z-index:1;height:22px;padding:0 10px;border:0;border-radius:6px;background:transparent;color:var(--fg-secondary);font-size:13px;line-height:20px;font-weight:500;white-space:nowrap;cursor:pointer;transition:color 120ms ease}
|
|
134
|
+
.fs-seg-btn[aria-checked=true]{color:var(--fg-primary)}
|
|
135
|
+
@media (hover:hover){.fs-seg-btn:hover{color:var(--fg-primary)}}
|
|
113
136
|
|
|
114
137
|
/* StateDot + 状态短句 */
|
|
115
|
-
.fs-state{display:inline-flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:20px;color:var(--
|
|
116
|
-
.fs-state>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
117
|
-
.fs-state[data-state=error]{color:var(--
|
|
118
|
-
.fs-dot{position:relative;display:inline-block;flex:none;width:10px;height:10px;color:var(--
|
|
138
|
+
.fs-state{display:inline-flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:20px;color:var(--fg-secondary)}
|
|
139
|
+
.fs-state>span:last-child{font-family:var(--fs-mono);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
140
|
+
.fs-state[data-state=error]{color:var(--status-error)}
|
|
141
|
+
.fs-dot{position:relative;display:inline-block;flex:none;width:10px;height:10px;color:var(--status-idle)}
|
|
119
142
|
.fs-dot::after{content:"";position:absolute;inset:20%;border-radius:50%;background:currentColor}
|
|
120
|
-
.fs-dot[data-state=done]{color:var(--
|
|
121
|
-
.fs-dot[data-state=warning]{color:var(--
|
|
122
|
-
.fs-dot[data-state=error]{color:var(--
|
|
123
|
-
.fs-spin{flex:none;color:var(--
|
|
143
|
+
.fs-dot[data-state=done]{color:var(--status-success)}
|
|
144
|
+
.fs-dot[data-state=warning]{color:var(--status-warn)}
|
|
145
|
+
.fs-dot[data-state=error]{color:var(--status-error)}
|
|
146
|
+
.fs-spin{flex:none;color:var(--fg-tertiary)}
|
|
124
147
|
.fs-spin g{transform-origin:center;animation:fs-spin 1.5s linear infinite}
|
|
125
148
|
.fs-spin circle{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
|
|
126
149
|
.fs-spin circle:first-child{opacity:.25}
|
|
@@ -129,66 +152,64 @@ select.fs-input{appearance:none;-webkit-appearance:none;padding-right:32px;curso
|
|
|
129
152
|
@keyframes fs-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}100%{stroke-dasharray:12 150;stroke-dashoffset:0}}
|
|
130
153
|
|
|
131
154
|
/* Tag / Pill */
|
|
132
|
-
.fs-tag{display:inline-flex;flex:none;align-items:center;gap:4px;padding:0 6px;border-radius:4px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--
|
|
133
|
-
.fs-tag[data-tone=success]{background:color-mix(in srgb,var(--
|
|
134
|
-
.fs-tag[data-tone=info]{background:
|
|
135
|
-
.fs-tag[data-tone=warning]{background:color-mix(in srgb,var(--
|
|
136
|
-
.fs-tag[data-tone=danger]{background:color-mix(in srgb,var(--
|
|
137
|
-
.fs-tag[data-tone=outline]{background:transparent;box-shadow:inset 0 0 0 1px var(--
|
|
138
|
-
.fs-tag>button{display:inline-flex;padding:0;border:0;background:none;color:var(--
|
|
139
|
-
@media (hover:hover){.fs-tag>button:hover{color:var(--
|
|
155
|
+
.fs-tag{font-family:var(--fs-mono);display:inline-flex;flex:none;align-items:center;gap:4px;padding:0 6px;border-radius:4px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--bg-sidebar);color:var(--fg-secondary);font-variant-numeric:tabular-nums}
|
|
156
|
+
.fs-tag[data-tone=success]{background:color-mix(in srgb,var(--status-success) 10%,transparent);color:var(--status-success)}
|
|
157
|
+
.fs-tag[data-tone=info]{background:var(--bg-active);color:var(--fg-primary)}
|
|
158
|
+
.fs-tag[data-tone=warning]{background:color-mix(in srgb,var(--status-warn) 12%,transparent);color:var(--status-warn)}
|
|
159
|
+
.fs-tag[data-tone=danger]{background:color-mix(in srgb,var(--status-error) 10%,transparent);color:var(--status-error)}
|
|
160
|
+
.fs-tag[data-tone=outline]{background:transparent;box-shadow:inset 0 0 0 1px var(--fs-line-strong);color:var(--fg-tertiary)}
|
|
161
|
+
.fs-tag>button{display:inline-flex;padding:0;border:0;background:none;color:var(--fg-tertiary);cursor:pointer}
|
|
162
|
+
@media (hover:hover){.fs-tag>button:hover{color:var(--status-error)}}
|
|
140
163
|
.fs-pills{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
|
141
|
-
.fs-pill{font-weight:400;display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 10px;border:0;border-radius:999px;background:var(--
|
|
142
|
-
@media (hover:hover){.fs-pill:hover{background:var(--
|
|
143
|
-
.fs-pill[aria-pressed=true]{background:var(--
|
|
164
|
+
.fs-pill{font-weight:400;display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 10px;border:0;border-radius:999px;background:var(--bg-sidebar);color:var(--fg-secondary);font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}
|
|
165
|
+
@media (hover:hover){.fs-pill:hover{background:var(--bg-hover)}}
|
|
166
|
+
.fs-pill[aria-pressed=true]{background:var(--bg-active);box-shadow:inset 0 0 0 1px var(--fs-line-strong);color:var(--fg-primary)}
|
|
144
167
|
|
|
145
168
|
/* 列表(记忆 / 待办 / 看过的视频 / 会话) */
|
|
146
169
|
.fs-list{display:flex;flex-direction:column}
|
|
147
|
-
.fs-item{display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 0;border-bottom:1px solid var(--
|
|
170
|
+
.fs-item{display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 0;border-bottom:1px solid var(--fs-line);font-size:13px;line-height:20px}
|
|
148
171
|
.fs-item:last-child{border-bottom:0}
|
|
149
172
|
.fs-item-main{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
150
|
-
.fs-item-meta{flex:none;display:flex;align-items:center;gap:10px;font:11px/16px var(--fs-mono);color:var(--
|
|
173
|
+
.fs-item-meta{flex:none;display:flex;align-items:center;gap:10px;font:11px/16px var(--fs-mono);color:var(--fg-tertiary);font-variant-numeric:tabular-nums}
|
|
151
174
|
.fs-item-meta a{color:inherit;text-decoration:none}
|
|
152
|
-
@media (hover:hover){.fs-item-meta a:hover{color:var(--
|
|
175
|
+
@media (hover:hover){.fs-item-meta a:hover{color:var(--fg-primary)}}
|
|
153
176
|
.fs-item .fs-icon-btn{opacity:0}
|
|
154
177
|
.fs-item:hover .fs-icon-btn,.fs-item:focus-within .fs-icon-btn{opacity:1}
|
|
155
178
|
@media (hover:none){.fs-item .fs-icon-btn{opacity:1}}
|
|
156
|
-
.fs-empty{padding:20px 0;text-align:center;font-size:13px;line-height:20px;color:var(--
|
|
179
|
+
.fs-empty{padding:20px 0;text-align:center;font-size:13px;line-height:20px;color:var(--fg-tertiary)}
|
|
157
180
|
|
|
158
181
|
/* 进度条 */
|
|
159
|
-
.fs-track{flex:0 1 180px;width:180px;min-width:60px;height:
|
|
160
|
-
.fs-fill{height:100%;border-radius:inherit;background:var(--
|
|
161
|
-
.fs-fill[data-state=warning]{background:var(--dsw-alias-state-warn-primary)}
|
|
162
|
-
.fs-fill[data-state=ongoing]{background:var(--dsw-alias-state-business-primary)}
|
|
182
|
+
.fs-track{flex:0 1 180px;width:180px;min-width:60px;height:5px;border-radius:999px;background:var(--bg-active);overflow:hidden}
|
|
183
|
+
.fs-fill{height:100%;border-radius:inherit;background:var(--accent);transition:width .25s ease}
|
|
163
184
|
|
|
164
185
|
/* 她此刻:大标题 + 日程条 */
|
|
165
186
|
.fs-now{display:flex;align-items:center;flex-wrap:wrap;gap:8px 10px;padding-bottom:12px}
|
|
166
187
|
.fs-now-title{font-size:15px;line-height:22px;font-weight:600}
|
|
167
|
-
.fs-now-meta{margin-left:auto;display:flex;align-items:center;gap:8px;font:12px/18px var(--fs-mono);color:var(--
|
|
188
|
+
.fs-now-meta{margin-left:auto;display:flex;align-items:center;gap:8px;font:12px/18px var(--fs-mono);color:var(--fg-secondary);font-variant-numeric:tabular-nums}
|
|
168
189
|
.fs-schedule{display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:0;border:1px solid var(--fs-line);border-radius:8px;overflow:hidden}
|
|
169
|
-
.fs-slot{display:flex;flex-direction:column;min-width:0;padding:6px 10px;border-left:1px solid var(--fs-line);font-size:12px;line-height:18px;color:var(--
|
|
190
|
+
.fs-slot{display:flex;flex-direction:column;min-width:0;padding:6px 10px;border-left:1px solid var(--fs-line);font-size:12px;line-height:18px;color:var(--fg-tertiary)}
|
|
170
191
|
.fs-slot>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
171
|
-
.fs-slot>span:first-child{font-weight:500;font-variant-numeric:tabular-nums;color:var(--
|
|
192
|
+
.fs-slot>span:first-child{font-weight:500;font-variant-numeric:tabular-nums;color:var(--fg-secondary)}
|
|
172
193
|
.fs-slot:first-child{border-left:0}
|
|
173
194
|
.fs-slot>span:first-child{font-family:var(--fs-mono)}
|
|
174
|
-
.fs-slot[aria-current=true]{background:var(--
|
|
175
|
-
.fs-slot[aria-current=true]>span:first-child{color:var(--
|
|
195
|
+
.fs-slot[aria-current=true]{background:var(--bg-sidebar);box-shadow:inset 0 -1px 0 var(--fg-secondary);color:var(--fg-primary)}
|
|
196
|
+
.fs-slot[aria-current=true]>span:first-child{color:var(--fg-primary)}
|
|
176
197
|
|
|
177
198
|
/* 提醒条 */
|
|
178
|
-
.fs-notice{display:flex;flex-direction:column;gap:2px;padding:8px 12px;border:1px solid color-mix(in srgb,var(--
|
|
179
|
-
.fs-notice b{font-weight:600;color:var(--
|
|
199
|
+
.fs-notice{display:flex;flex-direction:column;gap:2px;padding:8px 12px;border:1px solid color-mix(in srgb,var(--status-error) 30%,transparent);border-radius:8px;background:color-mix(in srgb,var(--status-error) 4%,transparent);font-size:13px;line-height:20px}
|
|
200
|
+
.fs-notice b{font-weight:600;color:var(--status-error)}
|
|
180
201
|
|
|
181
202
|
/* 截图 / 二维码 / 报告 */
|
|
182
|
-
.fs-shot{display:block;margin:4px 0 12px;width:100%;overflow:hidden;border-radius:8px;border:1px solid var(--
|
|
203
|
+
.fs-shot{display:block;margin:4px 0 12px;width:100%;overflow:hidden;border-radius:8px;border:1px solid var(--fs-line-strong);background:var(--bg-surface);cursor:pointer}
|
|
183
204
|
.fs-shot img{display:block;width:100%;height:auto;user-select:none}
|
|
184
205
|
.fs-shot .fs-empty{padding:64px 0}
|
|
185
|
-
.fs-qr{display:flex;align-items:center;gap:16px;padding:4px 0 16px;border-bottom:1px solid var(--
|
|
206
|
+
.fs-qr{display:flex;align-items:center;gap:16px;padding:4px 0 16px;border-bottom:1px solid var(--fs-line)}
|
|
186
207
|
.fs-qr img{flex:none;width:160px;height:160px;padding:8px;border-radius:12px;background:#fff}
|
|
187
208
|
.fs-group>.fs-report{margin-bottom:4px}
|
|
188
|
-
.fs-report{max-height:220px;overflow:auto;padding:8px 10px;border:1px solid var(--fs-line);border-radius:6px;background:var(--
|
|
209
|
+
.fs-report{max-height:220px;overflow:auto;padding:8px 10px;border:1px solid var(--fs-line);border-radius:6px;background:var(--bg-sidebar);font:11px/17px var(--fs-mono);white-space:pre-wrap;word-break:break-all;color:var(--fg-secondary)}
|
|
189
210
|
|
|
190
211
|
/* Toast:宿主 Toast 的位置与表面 */
|
|
191
|
-
.fs-toast{position:fixed;top:40px;left:50%;z-index:1100;width:max-content;max-width:min(560px,calc(100vw - 48px));padding:6px 12px;border-radius:8px;font-family:var(--fs-mono);font-size:12px !important;line-height:18px !important;background:var(--
|
|
212
|
+
.fs-toast{position:fixed;top:40px;left:50%;z-index:1100;width:max-content;max-width:min(560px,calc(100vw - 48px));padding:6px 12px;border-radius:8px;font-family:var(--fs-mono);font-size:12px !important;line-height:18px !important;background:var(--bg-control);color:var(--fg-primary);font-size:14px;line-height:22px;pointer-events:none;transform:translateX(-50%);animation:fs-toast-in 160ms ease-out}
|
|
192
213
|
@keyframes fs-toast-in{from{opacity:0;transform:translate(-50%,-6px)}to{opacity:1;transform:translate(-50%,0)}}
|
|
193
214
|
|
|
194
215
|
@media (prefers-reduced-motion:reduce){.fs-seg-ind,.fs-tab,.fs-seg-btn,.fs-switch-thumb,.fs-fill{transition:none}.fs-spin g,.fs-spin circle:last-child,.fs-toast{animation:none}}
|
|
@@ -196,77 +217,86 @@ select.fs-input{appearance:none;-webkit-appearance:none;padding-right:32px;curso
|
|
|
196
217
|
a.fs-btn{text-decoration:none}
|
|
197
218
|
.fs-item.edit{flex-direction:column;align-items:stretch;gap:8px}
|
|
198
219
|
.fs-item.edit .fs-ctl{justify-content:flex-start;flex-wrap:wrap}
|
|
199
|
-
.fs-item-sub{font-size:12px;line-height:18px;color:var(--
|
|
220
|
+
.fs-item-sub{font-size:12px;line-height:18px;color:var(--fg-tertiary);overflow-wrap:anywhere}
|
|
200
221
|
.fs-fill-gap{flex:1 1 auto}
|
|
201
|
-
.fs-check{display:inline-flex;flex:none;align-items:center;gap:4px;font-size:12px;line-height:18px;color:var(--
|
|
202
|
-
.fs-check input{margin:0;accent-color:var(--
|
|
203
|
-
.fs-strong{font-weight:500;color:var(--
|
|
222
|
+
.fs-check{display:inline-flex;flex:none;align-items:center;gap:4px;font-size:12px;line-height:18px;color:var(--fg-secondary);cursor:pointer}
|
|
223
|
+
.fs-check input{margin:0;accent-color:var(--fg-secondary)}
|
|
224
|
+
.fs-strong{font-weight:500;color:var(--fg-primary)}
|
|
204
225
|
.fs-group-aside{display:inline-flex;align-items:center;gap:8px;font-weight:400}
|
|
205
226
|
.fs-stickers{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:8px;padding:8px 0}
|
|
206
|
-
.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(--
|
|
227
|
+
.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)}
|
|
207
228
|
.fs-sticker img{width:64px;height:64px;object-fit:contain}
|
|
208
229
|
.fs-sticker figcaption{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
230
|
+
.fs-sticker figcaption .fs-input{flex:none;width:100%;height:24px;padding:0 4px;font-size:12px;text-align:center}
|
|
209
231
|
.fs-sticker .fs-icon-btn{position:absolute;top:2px;right:2px;opacity:0}
|
|
210
232
|
.fs-sticker:hover .fs-icon-btn,.fs-sticker:focus-within .fs-icon-btn{opacity:1}
|
|
211
233
|
/* 人格页:模板卡片、说话示例气泡、世界书编辑 */
|
|
212
|
-
.fs-persona{padding:10px 0;border-bottom:1px solid var(--
|
|
234
|
+
.fs-persona{padding:10px 0;border-bottom:1px solid var(--fs-line)}
|
|
213
235
|
.fs-persona:last-child{border-bottom:0}
|
|
214
236
|
.fs-persona-head{display:flex;align-items:center;gap:12px}
|
|
215
237
|
.fs-persona-title{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;font-size:13px;line-height:20px}
|
|
216
238
|
.fs-persona-title .fs-row-desc{flex-basis:100%}
|
|
217
239
|
.fs-persona-body{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
|
218
|
-
.fs-persona-desc{margin:0;font-size:12px;line-height:19px;color:var(--
|
|
240
|
+
.fs-persona-desc{margin:0;font-size:12px;line-height:19px;color:var(--fg-secondary);white-space:pre-wrap}
|
|
219
241
|
.fs-persona-foot{justify-content:flex-end;padding-top:10px}
|
|
220
242
|
.fs-tags{display:flex;flex-wrap:wrap;gap:6px}
|
|
221
|
-
.fs-chat{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border:1px solid var(--fs-line);border-radius:8px;background:var(--
|
|
243
|
+
.fs-chat{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border:1px solid var(--fs-line);border-radius:8px;background:var(--bg-sidebar);font-size:13px;line-height:20px}
|
|
222
244
|
.fs-chat-sep{height:4px}
|
|
223
245
|
.fs-chat-sep:first-child{display:none}
|
|
224
|
-
.fs-bubble{align-self:flex-start;max-width:80%;padding:4px 10px;border-radius:10px;background:var(--
|
|
225
|
-
.fs-bubble[data-me]{align-self:flex-end;background:
|
|
246
|
+
.fs-bubble{align-self:flex-start;max-width:80%;padding:4px 10px;border-radius:10px;background:var(--bg-surface);box-shadow:inset 0 0 0 1px var(--fs-line);color:var(--fg-primary);white-space:pre-wrap}
|
|
247
|
+
.fs-bubble[data-me]{align-self:flex-end;background:var(--bg-active);box-shadow:none}
|
|
226
248
|
.fs-lore{display:flex;flex-direction:column;gap:8px;width:100%}
|
|
227
249
|
.fs-lore-item{display:grid;grid-template-columns:minmax(120px,1fr) 3fr auto;gap:8px;align-items:start}
|
|
228
250
|
|
|
229
251
|
/* 执行步骤胶囊:单行状态条,默认收起,点开看详情 */
|
|
230
252
|
.fs-steps{display:flex;flex-direction:column;gap:4px;padding-top:6px}
|
|
231
|
-
.fs-step{min-width:0;border:1px solid var(--fs-line);border-radius:6px;background:var(--
|
|
232
|
-
.fs-step[open]{background:var(--
|
|
253
|
+
.fs-step{min-width:0;border:1px solid var(--fs-line);border-radius:6px;background:var(--bg-surface)}
|
|
254
|
+
.fs-step[open]{background:var(--bg-sidebar)}
|
|
233
255
|
.fs-step-head{display:flex;align-items:center;gap:8px;min-height:28px;padding:3px 8px;font-size:12px;line-height:18px;list-style:none;cursor:default}
|
|
234
256
|
summary.fs-step-head{cursor:pointer}
|
|
235
257
|
summary.fs-step-head::-webkit-details-marker{display:none}
|
|
236
|
-
summary.fs-step-head>svg{flex:none;color:var(--
|
|
258
|
+
summary.fs-step-head>svg{flex:none;color:var(--fg-tertiary);transform:rotate(-90deg);transition:transform 120ms ease}
|
|
237
259
|
.fs-step[open]>summary>svg{transform:none}
|
|
238
|
-
@media (hover:hover){summary.fs-step-head:hover{background:var(--
|
|
239
|
-
.fs-step-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--
|
|
240
|
-
.fs-step-dot[data-tone=success]{background:var(--
|
|
241
|
-
.fs-step-dot[data-tone=info]{background:var(--
|
|
242
|
-
.fs-step-dot[data-tone=warning]{background:var(--
|
|
243
|
-
.fs-step-dot[data-tone=danger]{background:var(--
|
|
244
|
-
.fs-step-kind{flex:none;min-width:3.5em;font:11px/16px var(--fs-mono);color:var(--
|
|
245
|
-
.fs-step .fs-item-main{color:var(--
|
|
246
|
-
.fs-step-body{display:flex;flex-direction:column;gap:4px;padding:6px 8px 8px 30px;border-top:1px solid var(--fs-line);font:11px/17px var(--fs-mono);color:var(--
|
|
260
|
+
@media (hover:hover){summary.fs-step-head:hover{background:var(--bg-hover)}}
|
|
261
|
+
.fs-step-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--fg-tertiary)}
|
|
262
|
+
.fs-step-dot[data-tone=success]{background:var(--status-success)}
|
|
263
|
+
.fs-step-dot[data-tone=info]{background:var(--fg-secondary)}
|
|
264
|
+
.fs-step-dot[data-tone=warning]{background:var(--status-warn)}
|
|
265
|
+
.fs-step-dot[data-tone=danger]{background:var(--status-error)}
|
|
266
|
+
.fs-step-kind{flex:none;min-width:3.5em;font:11px/16px var(--fs-mono);color:var(--fg-secondary)}
|
|
267
|
+
.fs-step .fs-item-main{color:var(--fg-secondary)}
|
|
268
|
+
.fs-step-body{display:flex;flex-direction:column;gap:4px;padding:6px 8px 8px 30px;border-top:1px solid var(--fs-line);font:11px/17px var(--fs-mono);color:var(--fg-secondary);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
247
269
|
.fs-step-body ul{display:flex;flex-direction:column;gap:2px;list-style:none}
|
|
248
|
-
.fs-step-body li::before{content:"› ";color:var(--
|
|
270
|
+
.fs-step-body li::before{content:"› ";color:var(--fg-tertiary)}
|
|
249
271
|
|
|
250
272
|
/* 一体化底部输入舱 */
|
|
251
|
-
.fs-dock{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;gap:6px;margin-top:6px;padding:4px 4px 4px 2px;border:1px solid var(--fs-line-strong);border-radius:10px;background:var(--
|
|
273
|
+
.fs-dock{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;gap:6px;margin-top:6px;padding:4px 4px 4px 2px;border:1px solid var(--fs-line-strong);border-radius:10px;background:var(--bg-control)}
|
|
252
274
|
.fs-dock .fs-input{height:28px;border-color:transparent;background:transparent}
|
|
253
275
|
.fs-dock .fs-input:focus-visible{border-color:transparent}
|
|
254
|
-
.fs-dock:focus-within{border-color:var(--
|
|
276
|
+
.fs-dock:focus-within{border-color:var(--accent)}
|
|
255
277
|
.fs-dock .fs-btn{flex:none}
|
|
256
278
|
|
|
257
279
|
/* 窄容器:侧栏变顶部横条,详情面板落到下方 */
|
|
258
280
|
@container (max-width:720px){
|
|
259
281
|
.fs,.fs.has-detail,.fs.nav-collapsed{grid-template-columns:minmax(0,1fr)}
|
|
260
|
-
.fs-side{position:
|
|
282
|
+
.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
|
+
.fs-side-inner{position:static;flex-direction:row;align-items:center;gap:6px;min-height:0}
|
|
284
|
+
.fs-main-head{position:static;height:28px;margin-top:0;padding-top:0;padding-bottom:10px}
|
|
261
285
|
.fs-side-head{margin:0;padding:0}.fs-side-head .fs-icon-btn{display:none}
|
|
262
|
-
|
|
286
|
+
/* 与宿主窄屏导航一样保留单行;键盘聚焦时浏览器会将目标滚入可视区。 */
|
|
287
|
+
.fs-nav{flex:1 1 auto;min-width:0;flex-direction:row;flex-wrap:nowrap;gap:2px;overflow-x:auto;overflow-y:hidden;scrollbar-width:thin}
|
|
288
|
+
.fs-tab{flex:none}
|
|
263
289
|
.fs-tab-key{display:none}
|
|
264
290
|
.fs.nav-collapsed .fs-tab-label{display:inline}
|
|
265
291
|
.fs.nav-collapsed .fs-tab{padding:0 8px}
|
|
266
292
|
.fs-side-foot{flex:none;margin:0;padding:0;border:0}
|
|
267
293
|
.fs-ver .fs-tab-label{display:none}
|
|
268
294
|
.fs-main{padding:12px 14px 18px}
|
|
269
|
-
.fs-
|
|
295
|
+
.fs-group-head{height:auto;min-height:24px;flex-wrap:wrap;gap:2px 8px}
|
|
296
|
+
.fs-group-head>:first-child{flex:none}
|
|
297
|
+
.fs-group-head>:nth-child(2){min-width:0;max-width:100%;overflow-wrap:anywhere}
|
|
298
|
+
.fs-detail-inner{position:static}
|
|
299
|
+
.fs-detail{border-left:0;border-top:1px solid var(--fs-line);border-radius:0 0 12px 12px}
|
|
270
300
|
.fs-row{flex-wrap:wrap;gap:6px 12px}.fs-row-title{max-width:none}
|
|
271
301
|
.fs-input,.fs-affix{flex:1 1 100%;width:100%}
|
|
272
302
|
.fs-dock .fs-input{flex:1 1 auto;width:auto}
|
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.2",
|
|
4
|
+
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.2) ",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"files": [
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"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/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs",
|
|
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 && node test/sticker-manage.mjs",
|
|
66
66
|
"eval": "node test/eval/persona-eval.mjs"
|
|
67
67
|
}
|
|
68
68
|
}
|