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 CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
4
4
 
5
- DSH 插件(单角色 `feiyu`)。版本 **0.15.0**。
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`,设置页在 DSH 的插件设置里(「人格 / 记忆 / QQ / B站 / 抖音 / 自走 / 模型 / 习惯 / 动态」)。
38
+ 插件注入 `webServer / tools / systemPrompt / llm / agents / workspaceRegistry`。在支持主面板的 DSH 版本中,左侧栏「鱼」可打开独立工作台;设置页入口也保留。旧宿主若不提供侧栏面板插槽,仍可从设置页打开(「人格 / 记忆 / QQ / B站 / 抖音 / 自走 / 模型 / 习惯 / 动态」)。
37
39
 
38
40
  已在 DSH **0.2.1-rc.5** 上验证;更早的 rc 版本未逐一验证,遇到问题请先升级复测。
39
41
 
@@ -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: "被戳了戳回去", meta: ia.pokeBack ? `有 ${pct(ia.pokeBackChance)}% 的时候戳回去(SnowLuma 不支持就算了)` : undefined },
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: "群里冷热", meta: ht.enabled ? `${Math.round(ht.windowSec / 60)} 分钟内有 ${ht.hotCount} 条、${ht.hotSpeakers} 个人在说就算热闹,插话概率 ×${ht.boost};她说完话 ${ht.coolMinutes} 分钟内慢慢恢复` : "插话只看概率和冷却" },
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-row-desc" }, Tag(label, tone), g.heat && g.heat.count > 0 ? Muted(` 最近 ${g.heat.count} 条 · ${g.heat.speakers} 人`) : null)),
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: "聊天用哪个模型", aside: Muted("单个聊天 → 私聊 / 群聊默认 → DSH 默认") },
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
- * - ctx.slots.inject("settings.section", ...) 挂载;样式随组件挂载 / 卸载(<style> 元素),不碰 document.head
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
- function Group({ title, aside }, ...kids) {
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, followUpSmart: qq.followUpSmart === true, 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(", ") }
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), followUpSmart: next.followUpSmart === true, batchMs: Number(next.batchMs), callNames: split(next.callNames),
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), masterQuiet: next.ij.masterQuiet !== false, masterQuietMinutes: Number(next.ij.masterQuietMinutes ?? 5) }
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 : "看到喜欢的表情会存下来,之后自己用" }, h(Switch, { label: "收藏表情包", checked: qq.stickers?.collect !== false, onChange: (v) => run("/stickers/config", { collect: v }, v ? "会收藏喜欢的表情了" : "不再收藏表情", "保存失败") })) : null,
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;
@@ -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
  }
@@ -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: "编辑人格", aside: Muted("记忆和关系不变,换的是她是谁、怎么说话") },
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 === "builtin"
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: "人格模板", aside: Muted("都是 SillyTavern V2 卡,换上以后还能接着改") },
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),
@@ -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, IconButton, Muted } = ui;
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
- const items = (data.items ?? []).filter((x) => x.exists);
433
- if (items.length === 0 && data.config?.collect === false) return null;
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
- const now = Date.now();
439
- return Group({ title: "她收藏的表情", aside: items.length ? Tag(String(items.length), "outline") : null },
440
- items.length === 0 ? Empty("还没收藏过。聊天里看到喜欢的表情,她会自己存下来") : h("div", { className: "fs-stickers" }, items.slice(0, 60).map((x) => h("figure", { className: "fs-sticker", key: `${x.workspacePath}/${x.file}`, title: [x.file, x.from && `从 ${x.from} 那里`, x.summary, relTime(x.at, now)].filter(Boolean).join("\n") },
441
- h("img", { alt: x.file, loading: "lazy", src: `${apiBase}/stickers/file?file=${encodeURIComponent(x.file)}&ws=${encodeURIComponent(x.workspacePath)}` }),
442
- h("figcaption", null, Muted(x.file.replace(/\.[a-z]+$/i, ""))),
443
- IconButton("close", "删除", () => remove(x))))));
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}`, aside: isGlobal ? Muted("排在每个聊天自己学的后面") : h("span", { className: "fs-group-aside" },
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
- /* dsh-fish-soul 设置面板(GET /api/plugins/fish-soul/ui/panel.css)
2
- * DeepSeek Harness 工作台质感:可折叠侧栏 + 居中主任务流 + 按需详情面板 + 一体化底部输入舱;
3
- * 1px 低对比细边框、背景色阶差、等宽数字 / 元信息,不用厚阴影与大色块。
4
- * 只引用 --dsw-alias-* 令牌,亮 / 暗主题跟随宿主;fs- 前缀,不引用宿主包。 */
5
- .fs{--fs-line:var(--dsw-alias-border-l2);--fs-line-strong:var(--dsw-alias-border-l3);--fs-mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,Consolas,"Liberation Mono",monospace;--fs-side:176px;--fs-detail:300px;
6
- 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(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
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(--dsw-alias-state-business-primary);outline-offset:1px}
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(--dsw-alias-label-tertiary)}
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{position:sticky;top:0;display:flex;flex-direction:column;gap:2px;min-width:0;align-self:stretch;padding:8px;border-right:1px solid var(--fs-line);background:var(--dsw-alias-bg-module-platform);border-radius:12px 0 0 12px}
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(--dsw-alias-label-secondary);font-size:13px;line-height:20px;text-align:left;white-space:nowrap;cursor:pointer;transition:background 120ms ease,color 120ms ease}
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(--dsw-alias-label-tertiary)}
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(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-weight:500}
27
- .fs-tab[aria-selected=true] .fs-tab-key{border-color:var(--fs-line-strong);color:var(--dsw-alias-label-primary)}
28
- @media (hover:hover){.fs-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}}
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(--dsw-alias-label-tertiary);cursor:pointer}
31
- @media (hover:hover){.fs-ver:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}
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:28px;padding-bottom:10px;border-bottom:1px solid var(--fs-line)}
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{position:sticky;top:0;display:flex;flex-direction:column;gap:12px;min-width:0;align-self:stretch;padding:14px 16px;border-left:1px solid var(--fs-line);background:var(--dsw-alias-bg-module-platform);border-radius:0 12px 12px 0}
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(--dsw-alias-label-tertiary)}
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-row-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
60
- .fs-row-meta{font:11px/16px var(--fs-mono);color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}
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(--dsw-alias-label-secondary);white-space:nowrap}
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(--dsw-alias-border-l3);border-radius:6px;background:var(--dsw-alias-bg-layer-3);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}
70
- .fs-input::placeholder{color:var(--dsw-alias-label-tertiary);opacity:.7}
71
- .fs .fs-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary)}
72
- .fs-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
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(--dsw-alias-label-tertiary);pointer-events:none}
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(--dsw-alias-border-l3);cursor:pointer}
85
- .fs-switch[aria-checked=true]{background:var(--dsw-alias-brand-primary)}
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(--dsw-alias-label-primary-foreground);transition:transform 120ms ease}
88
- .fs-switch[aria-checked=false] .fs-switch-thumb{background:var(--dsw-alias-switch-thumb)}
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(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}
93
- .fs-btn.primary{box-shadow:none;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
94
- .fs-btn.ghost{box-shadow:none;color:var(--dsw-alias-label-secondary)}
95
- .fs-btn.danger{color:var(--dsw-alias-state-error-primary)}
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(--dsw-alias-interactive-bg-hover)}
98
- .fs-btn.ghost:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
99
- .fs-btn.primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
100
- .fs-btn.danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
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(--dsw-alias-interactive-bg-active)}
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(--dsw-alias-label-tertiary);cursor:pointer}
105
- @media (hover:hover){.fs-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}
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(--dsw-alias-bg-module-platform)}
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(--dsw-alias-bg-layer-1);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2);transform:translateX(calc(var(--i) * (100% + 2px)));transition:transform 160ms ease;pointer-events:none}
110
- .fs-seg-btn{position:relative;z-index:1;height:22px;padding:0 10px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;font-weight:500;white-space:nowrap;cursor:pointer;transition:color 120ms ease}
111
- .fs-seg-btn[aria-checked=true]{color:var(--dsw-alias-label-primary)}
112
- @media (hover:hover){.fs-seg-btn:hover{color:var(--dsw-alias-label-primary)}}
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(--dsw-alias-label-secondary)}
116
- .fs-state>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
117
- .fs-state[data-state=error]{color:var(--dsw-alias-state-error-primary)}
118
- .fs-dot{position:relative;display:inline-block;flex:none;width:10px;height:10px;color:var(--dsw-alias-state-idle-primary)}
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(--dsw-alias-state-success-primary)}
121
- .fs-dot[data-state=warning]{color:var(--dsw-alias-state-warn-primary)}
122
- .fs-dot[data-state=error]{color:var(--dsw-alias-state-error-primary)}
123
- .fs-spin{flex:none;color:var(--dsw-alias-label-tertiary)}
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(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
133
- .fs-tag[data-tone=success]{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary)}
134
- .fs-tag[data-tone=info]{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
135
- .fs-tag[data-tone=warning]{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-primary)}
136
- .fs-tag[data-tone=danger]{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
137
- .fs-tag[data-tone=outline]{background:transparent;box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary)}
138
- .fs-tag>button{display:inline-flex;padding:0;border:0;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer}
139
- @media (hover:hover){.fs-tag>button:hover{color:var(--dsw-alias-state-error-primary)}}
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(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}
142
- @media (hover:hover){.fs-pill:hover{background:var(--dsw-alias-interactive-bg-hover)}}
143
- .fs-pill[aria-pressed=true]{background:var(--dsw-alias-button-ghost-active-fill);box-shadow:inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);color:var(--dsw-alias-label-primary)}
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(--dsw-alias-border-l2);font-size:13px;line-height:20px}
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(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
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(--dsw-alias-label-primary)}}
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(--dsw-alias-label-tertiary)}
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:4px;border-radius:999px;background:var(--dsw-alias-interactive-bg-active);overflow:hidden}
160
- .fs-fill{height:100%;border-radius:inherit;background:var(--dsw-alias-brand-primary);transition:width .25s ease}
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(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
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(--dsw-alias-label-tertiary)}
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(--dsw-alias-label-secondary)}
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(--dsw-alias-bg-module-platform);box-shadow:inset 0 -2px 0 var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
175
- .fs-slot[aria-current=true]>span:first-child{color:var(--dsw-alias-label-primary)}
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(--dsw-alias-state-error-primary) 30%,transparent);border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 4%,transparent);font-size:13px;line-height:20px}
179
- .fs-notice b{font-weight:600;color:var(--dsw-alias-state-error-primary)}
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(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);cursor:pointer}
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(--dsw-alias-border-l2)}
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(--dsw-alias-bg-module-platform);font:11px/17px var(--fs-mono);white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-secondary)}
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(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;pointer-events:none;transform:translateX(-50%);animation:fs-toast-in 160ms ease-out}
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(--dsw-alias-label-tertiary);overflow-wrap:anywhere}
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(--dsw-alias-label-secondary);cursor:pointer}
202
- .fs-check input{margin:0;accent-color:var(--dsw-alias-state-business-primary)}
203
- .fs-strong{font-weight:500;color:var(--dsw-alias-label-primary)}
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(--dsw-alias-bg-module-platform)}
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(--dsw-alias-border-l2)}
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(--dsw-alias-label-secondary);white-space:pre-wrap}
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(--dsw-alias-bg-module-platform);font-size:13px;line-height:20px}
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(--dsw-alias-bg-layer-1);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);white-space:pre-wrap}
225
- .fs-bubble[data-me]{align-self:flex-end;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);box-shadow:none}
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(--dsw-alias-bg-layer-1)}
232
- .fs-step[open]{background:var(--dsw-alias-bg-module-platform)}
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(--dsw-alias-label-tertiary);transform:rotate(-90deg);transition:transform 120ms ease}
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(--dsw-alias-interactive-bg-hover)}}
239
- .fs-step-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}
240
- .fs-step-dot[data-tone=success]{background:var(--dsw-alias-state-success-primary)}
241
- .fs-step-dot[data-tone=info]{background:var(--dsw-alias-state-business-primary)}
242
- .fs-step-dot[data-tone=warning]{background:var(--dsw-alias-state-warn-primary)}
243
- .fs-step-dot[data-tone=danger]{background:var(--dsw-alias-state-error-primary)}
244
- .fs-step-kind{flex:none;min-width:3.5em;font:11px/16px var(--fs-mono);color:var(--dsw-alias-label-secondary)}
245
- .fs-step .fs-item-main{color:var(--dsw-alias-label-secondary)}
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(--dsw-alias-label-secondary);white-space:pre-wrap;overflow-wrap:anywhere}
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(--dsw-alias-label-tertiary)}
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(--dsw-alias-bg-layer-3)}
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(--dsw-alias-state-business-primary)}
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:static;flex-direction:row;align-items:flex-start;gap:6px;padding:6px;border-right:0;border-bottom:1px solid var(--fs-line);border-radius:12px 12px 0 0}
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
- .fs-nav{flex:1 1 auto;flex-direction:row;flex-wrap:wrap;gap:2px}
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-detail{position:static;border-left:0;border-top:1px solid var(--fs-line);border-radius:0 0 12px 12px}
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
- const hit = stickers?.collected().find((x) => x.file === file && x.workspacePath === ws);
234
+ // 只有清单里真实存在的才给读(收藏的 + 主人自己放进 _stickers 的)
235
+ const hit = knownWorkspace(ws) ? stickers?.list(ws).find((x) => x.file === file && sameWorkspace(x.workspacePath, ws) && x.exists === true) : null;
219
236
  const ext = file.split('.').pop()?.toLowerCase() ?? '';
220
- const full = hit ? join(ws, '_stickers', file) : '';
237
+ const full = hit ? join(hit.workspacePath, '_stickers', file) : '';
221
238
  if (!hit || !MIME[ext] || !existsSync(full)) { send(404, { ok: false, error: 'not found' }); return true; }
222
239
  res.writeHead(200, { 'content-type': MIME[ext], 'cache-control': 'private, max-age=300' });
223
240
  res.end(readFileSync(full));
@@ -233,11 +250,45 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
233
250
  send(200, { ok: true, config: stickerConfig({ ...(getConfig().stickers ?? {}), ...s }) });
234
251
  return true;
235
252
  }
253
+ if (path === '/stickers/rename' && post) {
254
+ if (!guard()) return true;
255
+ const b = await body();
256
+ if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
257
+ const ws = String(b.workspacePath ?? '');
258
+ if (!knownWorkspace(ws)) { send(200, { ok: false, error: '没有这张' }); return true; }
259
+ const r = stickers.rename(basename(String(b.file ?? '')), String(b.name ?? ''), ws);
260
+ send(200, r.ok ? { ok: true, file: r.file } : { ok: false, error: r.reason ?? '改名失败' });
261
+ return true;
262
+ }
236
263
  if (path === '/stickers/delete' && post) {
237
264
  if (!guard()) return true;
238
265
  const b = await body();
239
- const ok = stickers?.remove(basename(String(b.file ?? '')), String(b.workspacePath ?? '')) === true;
240
- send(200, ok ? { ok: true } : { ok: false, error: '没有这张(只能删她自己收藏的)' });
266
+ const ws = String(b.workspacePath ?? '');
267
+ const ok = knownWorkspace(ws) && stickers?.remove(basename(String(b.file ?? '')), ws) === true;
268
+ send(200, ok ? { ok: true } : { ok: false, error: '没有这张' });
269
+ return true;
270
+ }
271
+ // 主人从面板导入本地图片:只进当前工作区,不占每天收藏的额度
272
+ if (path === '/stickers/import' && post) {
273
+ if (!guard()) return true;
274
+ if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
275
+ const ws = gateway?.workspacePath?.() ?? '';
276
+ if (ws === '') { send(200, { ok: false, error: 'QQ 工作区还没准备好(先连一次 QQ)' }); return true; }
277
+ let raw = '';
278
+ try { raw = await readBody(req); } catch { send(200, { ok: false, error: '请求没读完' }); return true; }
279
+ if (raw.length > IMPORT_BODY_MAX) { send(200, { ok: false, error: '图片太大了(超过 4MB)' }); return true; }
280
+ let b;
281
+ try { b = JSON.parse(raw || '{}'); } catch { send(200, { ok: false, error: '请求格式不对' }); return true; }
282
+ const b64 = String(b.base64 ?? '');
283
+ if (!isBase64(b64)) { send(200, { ok: false, error: '图片编码不对' }); return true; }
284
+ const bytes = Buffer.from(b64, 'base64');
285
+ if (bytes.length === 0) { send(200, { ok: false, error: '没有图片内容' }); return true; }
286
+ if (bytes.length > IMPORT_BYTES_MAX) { send(200, { ok: false, error: '图片太大了(超过 4MB)' }); return true; }
287
+ if (b.mediaType !== undefined && b.mediaType !== '' && !MEDIA_TYPES.has(String(b.mediaType))) { send(200, { ok: false, error: '图片类型不对' }); return true; }
288
+ const mediaType = sniffImage(bytes);
289
+ if (mediaType === null) { send(200, { ok: false, error: '不是支持的图片格式' }); return true; }
290
+ const r = stickers.importImage({ bytes, mediaType, name: String(b.name ?? ''), workspacePath: ws, from: '主人' });
291
+ send(200, r.ok ? { ok: true, file: r.file } : { ok: false, error: r.reason ?? '导入失败', full: r.full === true, dup: r.dup === true });
241
292
  return true;
242
293
  }
243
294
 
package/lib/stickers.js CHANGED
@@ -4,16 +4,25 @@
4
4
  * 网关把每轮转进会话的图片(字节 + 是不是表情)记在这里;模型在回复里写一行 [收藏表情:名字],
5
5
  * 就把这轮(或最近 10 分钟内)对方发的那张表情存到工作区 _stickers/名字.扩展名,以后能用 ![名字](_stickers/…) 发。
6
6
  * 同一张图(sha1)只存一次;每天最多存 perDay 张;_stickers 里最多 300 张。
7
+ * _stickers 目录里的文件本身就是本体:主人自己放进去的图也在面板清单里(不写 kv),能改名、能删。
7
8
  */
8
9
  import { createHash } from 'node:crypto';
9
- import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
10
- import { join } from 'node:path';
10
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { basename, extname, join, resolve } from 'node:path';
11
12
  import { getKv, setKv } from './db.js';
12
13
 
13
14
  export const COLLECT_RE = /\[(?:收藏表情|存表情)\s*[::]\s*([^\]\n]{1,30})\]/g;
14
15
  const RECENT_MS = 10 * 60_000;
15
16
  const DIR_CAP = 300;
16
17
  const EXT = { 'image/png': 'png', 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
18
+ const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
19
+
20
+ /** 同一个工作区?路径写法可能不一样(相对 / 绝对),比绝对路径。 */
21
+ const sameWs = (a, b) => {
22
+ const x = String(a ?? ''); const y = String(b ?? '');
23
+ if (x === '' || y === '') return false;
24
+ try { return resolve(x) === resolve(y); } catch { return x === y; }
25
+ };
17
26
 
18
27
  /** 回复里的 [收藏表情:名字] 抠出来(发出去的文字里去掉)。 */
19
28
  export function parseCollect(text) {
@@ -60,6 +69,59 @@ export class StickerBox {
60
69
  return getKv(this.db, 'stickers.collected', []) ?? [];
61
70
  }
62
71
 
72
+ /**
73
+ * 面板清单:收藏记录(含文件已经没了的)+ 当前工作区里主人自己放进 _stickers 的图。
74
+ * 目录里的图不写 kv、不定时扫,面板打开时现算。
75
+ * @returns {{file: string, workspacePath: string, from: string, summary: string, at: number, exists: boolean, tracked: boolean}[]}
76
+ */
77
+ list(workspacePath) {
78
+ const ws = String(workspacePath ?? '').trim();
79
+ const rows = this.collected();
80
+ const items = rows.map((x) => ({
81
+ file: x.file, workspacePath: x.workspacePath, from: x.from ?? '', summary: x.summary ?? '', at: x.at ?? 0,
82
+ exists: existsSync(join(x.workspacePath, '_stickers', x.file)), tracked: true
83
+ }));
84
+ if (ws === '') return items;
85
+ const known = new Set(rows.filter((x) => sameWs(x.workspacePath, ws)).map((x) => x.file));
86
+ let names = [];
87
+ try { names = readdirSync(join(ws, '_stickers')); } catch { return items; }
88
+ for (const name of names) {
89
+ if (known.has(name) || !IMAGE_EXT.has(extname(name).slice(1).toLowerCase())) continue;
90
+ let at = 0;
91
+ try { at = statSync(join(ws, '_stickers', name)).mtimeMs; } catch { /* 读不到就当没有 */ }
92
+ items.push({ file: name, workspacePath: ws, from: '主人放的', summary: '', at, exists: true, tracked: false });
93
+ }
94
+ return items;
95
+ }
96
+
97
+ /**
98
+ * 改名字:文件真的在 _stickers 里才改;撞名直接拒绝(不自动加序号);收藏记录里的文件名跟着改,主人自己放的不写 kv。
99
+ * @returns {{ok: boolean, file?: string, reason?: string}}
100
+ */
101
+ rename(file, newName, workspacePath) {
102
+ const ws = String(workspacePath ?? '');
103
+ const old = basename(String(file ?? ''));
104
+ if (old === '') return { ok: false, reason: '没有这张' };
105
+ const all = this.collected();
106
+ const hit = all.find((x) => x.file === old && sameWs(x.workspacePath, ws));
107
+ const dir = join(hit?.workspacePath ?? ws, '_stickers');
108
+ const from = join(dir, old);
109
+ if (!existsSync(from)) return { ok: false, reason: '没有这张' };
110
+ const ext = extname(old).slice(1).toLowerCase();
111
+ if (!IMAGE_EXT.has(ext)) return { ok: false, reason: '只支持图片文件' };
112
+ const base = stickerName(basename(String(newName ?? '')));
113
+ if (base === '') return { ok: false, reason: '名字不能用' };
114
+ const target = `${base}.${ext}`;
115
+ if (target === old) return { ok: true, file: old };
116
+ if (existsSync(join(dir, target))) return { ok: false, reason: '已经有同名了' };
117
+ try { renameSync(from, join(dir, target)); } catch { return { ok: false, reason: '改名失败' }; }
118
+ if (hit) {
119
+ hit.file = target;
120
+ setKv(this.db, 'stickers.collected', all.slice(0, DIR_CAP));
121
+ }
122
+ return { ok: true, file: target };
123
+ }
124
+
63
125
  /**
64
126
  * 存最近一张(优先表情)为 名字.扩展名。
65
127
  * @returns {{ok: boolean, file?: string, reason?: string}}
@@ -114,13 +176,17 @@ export class StickerBox {
114
176
  return { ok: true, file };
115
177
  }
116
178
 
117
- /** 设置页删一张她收藏的(只删她自己收藏的,不碰主人放的)。 */
179
+ /** 设置页删一张:收藏的和主人自己放的都能删;文件已经没了就把收藏记录也删掉。 */
118
180
  remove(file, workspacePath) {
181
+ const ws = String(workspacePath ?? '');
182
+ const name = basename(String(file ?? ''));
183
+ if (name === '') return false;
119
184
  const all = this.collected();
120
- const hit = all.find((x) => x.file === file && x.workspacePath === workspacePath);
121
- if (!hit) return false;
122
- try { unlinkSync(join(workspacePath, '_stickers', hit.file)); } catch { /* 已经没了 */ }
123
- setKv(this.db, 'stickers.collected', all.filter((x) => x !== hit));
185
+ const hit = all.find((x) => x.file === name && sameWs(x.workspacePath, ws));
186
+ const path = join(hit?.workspacePath ?? ws, '_stickers', name);
187
+ if (!hit && !existsSync(path)) return false;
188
+ try { unlinkSync(path); } catch { /* 已经没了 */ }
189
+ if (hit) setKv(this.db, 'stickers.collected', all.filter((x) => x !== hit));
124
190
  return true;
125
191
  }
126
192
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.15.0",
4
- "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.0) ",
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
  }