dsh-fish-soul 0.15.7 → 0.15.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
4
4
 
5
- DSH 插件(单角色 `feiyu`)。版本 **0.15.7**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.8**。
6
6
 
7
7
  QQ 交流群:**1125962189**
8
8
 
package/lib/behavior.js CHANGED
@@ -486,10 +486,8 @@ export class DiaryWriter {
486
486
  AND created_at >= ? AND created_at < ? ORDER BY created_at LIMIT 10`).all(start, end);
487
487
  const recent = db.prepare(`SELECT content FROM memory WHERE character_id = 'feiyu' AND owner_id = 'self' AND kind = 'diary' AND status = 'active'
488
488
  AND created_at < ? ORDER BY created_at DESC LIMIT 3`).all(start).map((x) => x.content.replace(/^[\d-]+ 的日记:/, '').replace(/\s+/g, ' ').slice(0, 120));
489
- const snap = this.life?.stateSnapshot?.() ?? null;
490
- const plan = snap?.today?.date === date ? snap.today : null;
489
+ // 日记只写真实发生的事:自走排程是虚拟计划不是经历,不进日记材料(2026-11-06 主人反馈)。
491
490
  return {
492
- tone: plan?.dailyMoodTone ?? '', schedule: (plan?.scheduleSummary ?? []).map((s) => `${s.range ?? ''} ${s.activity ?? ''}`.trim()),
493
491
  episodes: eps.map((e) => `${e.name ? `和${e.name}:` : ''}${e.content}`), self: self.map((s) => s.content), recent
494
492
  };
495
493
  }
@@ -520,8 +518,6 @@ export class DiaryWriter {
520
518
  `- 不要提 AI、模型、系统这些词,也不要任何关于「写日记」这件事本身的说明。\n` +
521
519
  `- 日期写 ${date} 就行,不要用「今天」开头的套话开头。\n` +
522
520
  (mat.recent.length ? `最近几篇日记(换一个新角度写,除非今天有新的事,别重复之前的写法和切入点):\n${mat.recent.map((e) => `- ${e}`).join('\n')}\n` : '') +
523
- (mat.tone ? `今天的基调:${mat.tone}\n` : '') +
524
- (mat.schedule.length ? `今天的安排:${mat.schedule.join(';')}\n` : '') +
525
521
  `今天聊过的:\n${mat.episodes.map((e) => `- ${e}`).join('\n') || '(没怎么聊天)'}\n` +
526
522
  (mat.self.length ? `今天自己说过的事:\n${mat.self.map((e) => `- ${e}`).join('\n')}\n` : '') +
527
523
  '今天实在平淡,日记就写一两句平淡本身也行,不要硬凑。\n' +
@@ -156,7 +156,7 @@ window.__ModuleLoader__.load({
156
156
  h("span", { className: "fs-item-meta" }, h("span", null, relTime(e.at, now)))))));
157
157
  }
158
158
 
159
- function BehaviorPane({ api, notify, relTime, ui }) {
159
+ function BehaviorPane({ api, notify, relTime, ui, section = "habits" }) {
160
160
  const { h, Empty, Dot, Group } = ui;
161
161
  const [data, load] = useLoad(api, "/behavior/state");
162
162
  if (data === null) return Empty(Dot("ongoing"));
@@ -167,11 +167,11 @@ window.__ModuleLoader__.load({
167
167
  }
168
168
  const groups = data.groups ?? [];
169
169
  return h(react.Fragment, null,
170
- h(Habits, { cfg: data.config, save, ui }),
171
- Group({ title: "每个群" }, groups.length === 0 ? Empty("还没有群(在 QQ 页填群白名单,或者等群里有人叫她)")
172
- : groups.map((g) => h(GroupNote, { key: g.groupId, g, api, notify, onSaved: load, ui }))),
173
- h(ChatModels, { data, api, notify, load, ui }),
174
- h(Diary, { data, save, api, notify, load, relTime, ui }));
170
+ section === "habits" ? h(Habits, { cfg: data.config, save, ui }) : null,
171
+ section === "groups" ? Group({ title: "每个群" }, groups.length === 0 ? Empty("还没有群(在 QQ 页填群白名单,或者等群里有人叫她)")
172
+ : groups.map((g) => h(GroupNote, { key: g.groupId, g, api, notify, onSaved: load, ui }))) : null,
173
+ section === "models" ? h(ChatModels, { data, api, notify, load, ui }) : null,
174
+ section === "diary" ? h(Diary, { data, save, api, notify, load, relTime, ui }) : null);
175
175
  }
176
176
 
177
177
  return { BehaviorPane };
package/lib/client.js CHANGED
@@ -152,33 +152,18 @@ window.__ModuleLoader__.load({
152
152
  }, h("span", { className: "fs-tab-key", "aria-hidden": true }, TAB_KEYS[v] ?? label.slice(0, 1)), h("span", { className: "fs-tab-label" }, label), v === "version" && hasUpdate ? h("span", { className: "fs-update-dot", "aria-hidden": true }) : null)));
153
153
  }
154
154
 
155
- // 两级导航保留原有页面,按任务分类;引导跳转仍以原页签 id 为真源。
156
- function GroupedTabs({ items, value, onChange, hasUpdate }) {
157
- const groups = NAV_GROUPS.map((group) => ({ ...group, items: items.filter(([id]) => group.ids.includes(id)) })).filter((group) => group.items.length > 0);
158
- const active = groups.find((group) => group.items.some(([id]) => id === value)) ?? groups[0];
159
- const remembered = react.useRef({});
160
- const groupRef = react.useRef(null);
161
- react.useEffect(() => {
162
- if (active) remembered.current[active.id] = value;
163
- const nav = groupRef.current;
164
- const selected = nav?.querySelector('[aria-pressed="true"]');
165
- if (!nav || !selected) return;
166
- const outer = nav.getBoundingClientRect(), inner = selected.getBoundingClientRect();
167
- if (inner.left < outer.left) nav.scrollLeft -= outer.left - inner.left;
168
- else if (inner.right > outer.right) nav.scrollLeft += inner.right - outer.right;
169
- }, [value]);
170
- return h(react.Fragment, null,
171
- h("div", { className: "fs-group-row" },
172
- h("span", { className: "fs-brand" }, "鱼"),
173
- h("nav", { className: "fs-group-nav", ref: groupRef, "aria-label": "功能分类" }, groups.map((group) => {
174
- const selected = group.id === active?.id;
175
- const showUpdate = hasUpdate && group.ids.includes("version");
176
- return h("button", { type: "button", key: group.id, className: "fs-group-tab", "aria-pressed": selected,
177
- "aria-label": showUpdate ? `${group.label}(有新版本)` : group.label,
178
- onClick: () => onChange(remembered.current[group.id] ?? group.items[0][0]) },
179
- group.label, showUpdate ? h("span", { className: "fs-update-dot", "aria-hidden": true }) : null);
180
- }))),
181
- h("div", { className: "fs-subnav-row" }, h(Tabs, { items: active?.items ?? [], value, onChange, horizontal: true, hasUpdate })));
155
+ // 大项是页面,不再让每个大项重复一个同名子页签。
156
+ function GroupedTabs({ value, onChange, hasUpdate }) {
157
+ const active = NAV_GROUPS.find((group) => group.id === value) ?? NAV_GROUPS[0];
158
+ return h("div", { className: "fs-group-row" },
159
+ h("span", { className: "fs-brand" }, "鱼"),
160
+ h("nav", { className: "fs-group-nav", "aria-label": "设置分类" }, NAV_GROUPS.map((group) => {
161
+ const selected = group.id === active.id;
162
+ const showUpdate = hasUpdate && group.id === "system";
163
+ return h("button", { type: "button", key: group.id, className: "fs-group-tab", "aria-pressed": selected,
164
+ "aria-label": showUpdate ? `${group.label}(有新版本)` : group.label, onClick: () => onChange(group.id) },
165
+ group.label, showUpdate ? h("span", { className: "fs-update-dot", "aria-hidden": true }) : null);
166
+ })));
182
167
  }
183
168
 
184
169
  /** StateDot:done / warning / error / idle 是圆点,ongoing 是转圈。 */
@@ -763,16 +748,18 @@ window.__ModuleLoader__.load({
763
748
  }
764
749
 
765
750
  // ── 面板根组件:页签 + 数据装载 + 轮询 ──
766
- const TAB_KEYS = { guide: "引", retrieval: "索", persona: "人", life: "自", inspector: "忆", activity: "动", behavior: "习", models: "模", qq: "Q", bili: "B", douyin: "抖", version: "版" };
767
- const TABS = [["retrieval", "记忆检索"], ["persona", "人格"], ["life", "自走"], ["inspector", "记忆检视"], ["activity", "动态"], ["behavior", "习惯"], ["models", "模型"], ["qq", "QQ"], ["bili", "B站"], ["douyin", "抖音"]];
768
- const NAV_GROUPS = [
769
- { id: "start", label: "开始", ids: ["guide"] },
770
- { id: "memory", label: "记忆", ids: ["retrieval", "inspector"] },
771
- { id: "character", label: "角色", ids: ["persona", "behavior"] },
772
- { id: "daily", label: "日常", ids: ["life", "activity"] },
773
- { id: "channels", label: "平台", ids: ["qq", "bili", "douyin"] },
774
- { id: "system", label: "系统", ids: ["models", "interface", "version"] }
751
+ const TAB_KEYS = { memory: "忆", character: "角", daily: "常", channels: "台", models: "模", system: "统", guide: "引", retrieval: "索", persona: "人", life: "自", inspector: "忆", activity: "动", behavior: "习", qq: "Q", bili: "B", douyin: "抖", version: "版" };
752
+ const TABS = [
753
+ ["memory", "记忆", [["retrieval", "检索设置"], ["people", "人物与关系"], ["memories", "记忆内容"], ["recall-test", "检索测试"], ["weekly", "周记"], ["archives", "记忆存档"], ["backups", "导入与备份"]]],
754
+ ["character", "角色", [["persona", "人格卡"], ["templates", "人格模板"], ["growth", "成长"], ["worldinfo", "世界书"], ["habits", "说话习惯"], ["groups", "群设定"], ["chat-models", "聊天模型"]]],
755
+ ["daily", "日常", [["life", "主动开口"], ["activity", "最近动态"], ["diary", "日记"], ["stickers", "表情包"], ["slang", "学到的说法"]]],
756
+ ["channels", "平台", [["qq", "QQ"], ["bili", "B站"], ["douyin", "抖音"]]],
757
+ ["models", "模型", [["models", "功能模型"], ["usage", "用量与价格"], ["web", "联网搜索"], ["voice", "语音"]]],
758
+ ["system", "系统", [["interface", "界面"], ["version", "版本更新"]]]
775
759
  ];
760
+ const NAV_GROUPS = TABS.map(([id, label]) => ({ id, label }));
761
+ const SUBTABS = Object.fromEntries(TABS.map(([id, , subs]) => [id, subs]));
762
+ const DEFAULT_TABS = Object.fromEntries(TABS.map(([id, , subs]) => [id, subs[0][0]]));
776
763
 
777
764
  // 服务端仍提供原始步骤与状态;短标题只调整展示,未知步骤保留服务端原文。
778
765
  const ONBOARDING_LABELS = { persona: "选择人格", model: "配置模型", qq: "连接 QQ", master: "设置主人 QQ", retrieval: "配置向量检索", import: "导入聊天记录", voice: "配置语音" };
@@ -859,9 +846,21 @@ window.__ModuleLoader__.load({
859
846
  const guideVisibilityKey = "fish-soul:guide-visible";
860
847
  const readGuideVisible = () => { try { return window.localStorage.getItem(guideVisibilityKey) !== "false"; } catch { return true; } };
861
848
  const [guideVisible, setGuideVisible] = react.useState(readGuideVisible);
862
- const [tab, setTab] = react.useState("retrieval");
849
+ const [tab, setTab] = react.useState("memory");
850
+ const [subtab, setSubtab] = react.useState("retrieval");
863
851
  const hasInteracted = react.useRef(false);
864
- const goTab = (next) => { hasInteracted.current = true; setTab(next); };
852
+ const remembered = react.useRef({});
853
+ const goTab = (next, child) => {
854
+ hasInteracted.current = true;
855
+ if (next === "guide") { setTab(next); return; }
856
+ if (SUBTABS[next]) setSubtab(child ?? remembered.current[next] ?? DEFAULT_TABS[next]);
857
+ setTab(next);
858
+ };
859
+ const goSubtab = (next) => { hasInteracted.current = true; remembered.current[tab] = next; setSubtab(next); };
860
+ const goSetting = (next) => {
861
+ const group = TABS.find(([, , subs]) => subs.some(([id]) => id === next));
862
+ if (group) goTab(group[0], next);
863
+ };
865
864
  const [retrieval, setRetrieval] = react.useState(null);
866
865
  const [life, setLife] = react.useState(null);
867
866
  const [channels, setChannels] = react.useState(null);
@@ -873,7 +872,7 @@ window.__ModuleLoader__.load({
873
872
  try { window.localStorage.setItem(guideVisibilityKey, String(visible)); }
874
873
  catch { notify("无法保存界面设置"); return; }
875
874
  setGuideVisible(visible);
876
- if (!visible) setTab((current) => current === "guide" ? "retrieval" : current);
875
+ if (!visible) setTab((current) => current === "guide" ? "memory" : current);
877
876
  window.dispatchEvent(new Event("fish-soul:guide-visibility"));
878
877
  };
879
878
  react.useEffect(() => {
@@ -904,14 +903,14 @@ window.__ModuleLoader__.load({
904
903
  }, []);
905
904
  // 检索页 2s 刷新(重建进度);QQ / B站 / 抖音 3s 刷新
906
905
  react.useEffect(() => {
907
- const fn = tab === "retrieval" ? loadRetrieval : tab === "qq" || tab === "bili" || tab === "douyin" ? loadChannels : null;
906
+ const fn = subtab === "retrieval" ? loadRetrieval : subtab === "qq" || subtab === "bili" || subtab === "douyin" ? loadChannels : null;
908
907
  if (fn === null) return undefined;
909
- const timer = setInterval(fn, tab === "retrieval" ? 2000 : 3000);
908
+ const timer = setInterval(fn, subtab === "retrieval" ? 2000 : 3000);
910
909
  return () => clearInterval(timer);
911
- }, [tab]);
910
+ }, [subtab]);
912
911
  // 虚拟时钟走秒(纯展示)
913
912
  react.useEffect(() => {
914
- if (tab !== "life") return undefined;
913
+ if (subtab !== "life") return undefined;
915
914
  const timer = setInterval(() => setLife((prev) => prev?.clock?.virtualNowMs
916
915
  ? { ...prev, clock: { ...prev.clock, virtualNowMs: prev.clock.virtualNowMs + 1000 * (prev.clock.timeScale ?? 1) } } : prev), 1000);
917
916
  return () => clearInterval(timer);
@@ -926,7 +925,7 @@ window.__ModuleLoader__.load({
926
925
 
927
926
  // 导航红点使用同一只读状态接口;版本页自身检查后的结果即时同步。
928
927
  react.useEffect(() => {
929
- if (tab === "version") return undefined;
928
+ if (subtab === "version") return undefined;
930
929
  let active = true;
931
930
  const loadUpdate = async () => { const r = await api("GET", "/update/state"); if (active && r?.ok) setUpdateAvailable(r.hasUpdate === true); };
932
931
  loadUpdate();
@@ -935,30 +934,31 @@ window.__ModuleLoader__.load({
935
934
  }, [tab]);
936
935
 
937
936
  if (css === null) return null;
938
- const pane = tab === "guide" ? h(Onboarding, { onGo: goTab, onDismiss: () => goTab("retrieval"), onHide: () => setGuideVisibility(false), notify })
939
- : tab === "interface" ? Row({ title: "显示开始" }, h(Switch, { label: "显示开始", checked: guideVisible, onChange: setGuideVisibility }))
940
- : tab === "version" ? h(Boundary, { name: "版本" }, h(UpdateBar, { notify, onUpdateState: setUpdateAvailable }))
941
- : tab === "retrieval" ? h(RetrievalPane, { data: retrieval, refresh: loadRetrieval, notify })
942
- : tab === "life" ? h(LifePane, { data: life, refresh: loadLife, notify })
943
- : tab === "inspector" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.InspectorPane, failText: "记忆检视加载失败", notify })
944
- : tab === "activity" ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.ActivityPane, failText: "动态加载失败", notify })
945
- : tab === "behavior" ? h(ChunkTab, { file: "client.behavior.js", pick: (m) => m.BehaviorPane, failText: "习惯页加载失败", notify })
946
- : tab === "models" ? h(ChunkTab, { file: "client.models.js", pick: (m) => m.ModelsPane, failText: "模型页加载失败", notify })
947
- : tab === "persona" ? h(ChunkTab, { file: "client.persona.js", pick: (m) => m.PersonaPane, failText: "人格页加载失败", notify })
948
- : tab === "qq" ? h(ChannelPane, { data: channels, refresh: loadChannels, notify })
949
- : tab === "bili" ? h(BiliPane, { data: channels, refresh: loadChannels, notify })
937
+ const pane = tab === "guide" ? h(Onboarding, { onGo: goSetting, onDismiss: () => goTab("memory"), onHide: () => setGuideVisibility(false), notify })
938
+ : subtab === "interface" ? Row({ title: "显示开始" }, h(Switch, { label: "显示开始", checked: guideVisible, onChange: setGuideVisibility }))
939
+ : subtab === "version" ? h(Boundary, { name: "版本" }, h(UpdateBar, { notify, onUpdateState: setUpdateAvailable }))
940
+ : subtab === "retrieval" ? h(RetrievalPane, { data: retrieval, refresh: loadRetrieval, notify })
941
+ : subtab === "life" ? h(LifePane, { data: life, refresh: loadLife, notify })
942
+ : ["people", "memories", "recall-test", "weekly", "archives", "backups"].includes(subtab) ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.InspectorPane, failText: "记忆管理加载失败", section: subtab, notify })
943
+ : ["activity", "stickers", "slang"].includes(subtab) ? h(ChunkTab, { file: "client.soul.js", pick: (m) => m.ActivityPane, failText: "动态加载失败", section: subtab, notify })
944
+ : ["habits", "groups", "chat-models", "diary"].includes(subtab) ? h(ChunkTab, { file: "client.behavior.js", pick: (m) => m.BehaviorPane, failText: "习惯页加载失败", section: subtab === "chat-models" ? "models" : subtab, notify })
945
+ : ["models", "usage", "web", "voice"].includes(subtab) ? h(ChunkTab, { file: "client.models.js", pick: (m) => m.ModelsPane, failText: "模型页加载失败", section: subtab, notify })
946
+ : ["persona", "templates", "growth", "worldinfo"].includes(subtab) ? h(ChunkTab, { file: "client.persona.js", pick: (m) => m.PersonaPane, failText: "人格页加载失败", section: subtab === "persona" ? "current" : subtab, notify })
947
+ : subtab === "qq" ? h(ChannelPane, { data: channels, refresh: loadChannels, notify })
948
+ : subtab === "bili" ? h(BiliPane, { data: channels, refresh: loadChannels, notify })
950
949
  : h(ChunkTab, { file: "client.douyin.js", pick: (m) => m.DouyinPane, failText: "抖音页加载失败", data: channels, refresh: loadChannels, notify });
951
950
 
952
- // 工作台:主页与设置页都把版本更新作为系统分组下的独立页签。
953
- const tabs = [...(guideVisible ? [["guide", "使用引导"]] : []), ...TABS, ["interface", "界面"], ["version", "版本更新"]];
954
- const label = tabs.find(([v]) => v === tab)?.[1] ?? "";
951
+ const tabs = tab === "guide" ? [] : SUBTABS[tab] ?? [];
952
+ const label = tab === "guide" ? "使用引导" : tabs.find(([v]) => v === subtab)?.[1] ?? "";
955
953
  return h("div", { className: "fs-host" }, h("div", { className: cx("fs", standalone ? "fs-standalone" : "fs-settings"), onPointerDown: () => { hasInteracted.current = true; }, onKeyDown: () => { hasInteracted.current = true; } },
956
954
  css === "" ? null : h("style", { "data-plugin-css": `${PLUGIN_ID}/panel.css` }, css),
957
955
  h("div", { className: "fs-side" }, h("div", { className: "fs-side-inner" },
958
- h(GroupedTabs, { items: tabs, value: tab, onChange: goTab, hasUpdate: updateAvailable }))),
956
+ h(GroupedTabs, { value: tab, onChange: goTab, hasUpdate: updateAvailable }),
957
+ guideVisible && tab !== "guide" ? h("div", { className: "fs-subnav-row" }, Button({ variant: "ghost", onClick: () => goTab("guide") }, "使用引导")) : null,
958
+ tabs.length > 0 ? h("div", { className: "fs-subnav-row" }, h(Tabs, { items: tabs, value: subtab, onChange: goSubtab, horizontal: true })) : null)),
959
959
  h("main", { className: "fs-main" },
960
- h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label), storageWarning ? h("span", { role: "status", style: { marginLeft: "auto" } }, Button({ variant: "ghost", onClick: () => goTab("inspector") }, storageWarning)) : null),
961
- h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${tab}`, key: tab }, h(Boundary, { name: "这一页" }, pane))),
960
+ h("header", { className: "fs-main-head" }, h("h2", { className: "fs-main-title" }, label), storageWarning ? h("span", { role: "status", style: { marginLeft: "auto" } }, Button({ variant: "ghost", onClick: () => goSetting("archives") }, storageWarning)) : null),
961
+ h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${subtab}`, key: `${tab}:${subtab}` }, h(Boundary, { name: "这一页" }, pane))),
962
962
  toast === null ? null : h("div", { className: "fs-toast", role: "status", key: toast.key }, toast.text)));
963
963
  }
964
964
 
@@ -199,13 +199,17 @@ window.__ModuleLoader__.load({
199
199
  tts.hasKey ? Row({ title: "试听" }, Input({ value: say, onChange: (e) => setSay(e.target.value), "aria-label": "试听的话" }), Button({ disabled: busy, onClick: test }, busy ? "合成中" : "播放")) : null) : null);
200
200
  }
201
201
 
202
- function ModelsPane({ api, notify, ui }) {
202
+ function ModelsPane({ api, notify, ui, section = "models" }) {
203
203
  const { h, Empty, Dot } = ui;
204
204
  const [data, load] = useLoad(api, "/models/state");
205
205
  if (data === null) return Empty(Dot("ongoing"));
206
206
  if (data.error) return h("div", { className: "fs-notice", role: "alert" }, h("b", null, "模型状态读不到"), h("span", null, data.error));
207
207
  const p = { data, api, notify, load, ui };
208
- return h(react.Fragment, null, h(FeatureModels, p), h(Usage, p), h(Web, p), h(Voice, { api, notify, ui }));
208
+ return h(react.Fragment, null,
209
+ section === "models" ? h(FeatureModels, p) : null,
210
+ section === "usage" ? h(Usage, p) : null,
211
+ section === "web" ? h(Web, p) : null,
212
+ section === "voice" ? h(Voice, { api, notify, ui }) : null);
209
213
  }
210
214
 
211
215
  return { ModelsPane };
@@ -203,7 +203,7 @@ window.__ModuleLoader__.load({
203
203
  confirmNode);
204
204
  }
205
205
 
206
- function PersonaPane({ api, notify, ui }) {
206
+ function PersonaPane({ api, notify, ui, section = "current" }) {
207
207
  const { h, Group, Row, Button, Switch, Tag, Muted, Empty, Dot } = ui;
208
208
  const [data, setData] = useLoad(api, "/persona/state");
209
209
  const [open, setOpen] = react.useState(null);
@@ -264,7 +264,7 @@ window.__ModuleLoader__.load({
264
264
  if (editing !== null) return h(Editor, { h, ui, card: editing, busy, onSave: saveCard, onCancel: () => setEditing(null) });
265
265
 
266
266
  return h(react.Fragment, null,
267
- Group({ title: "现在的人格", aside: data.canUndo ? Button({ variant: "ghost", disabled: busy, onClick: undo }, "撤销上次更换") : null },
267
+ section === "current" ? Group({ title: "现在的人格", aside: data.canUndo ? Button({ variant: "ghost", disabled: busy, onClick: undo }, "撤销上次更换") : null },
268
268
  Row({ title: "名字", desc: cur.source === "persona.png" ? "从 persona.png 读的;在这里改了会另存成 persona.json" : undefined },
269
269
  h("b", null, cur.name), Tag(SOURCE[cur.source] ?? cur.source, cur.source === "builtin" ? "outline" : undefined)),
270
270
  cur.source !== "builtin" ? Row({ title: "一句话人设", stack: true }, h("p", { className: "fs-persona-desc" }, cur.anchor || "没写(会用性格那一栏顶上)")) : null,
@@ -276,16 +276,16 @@ window.__ModuleLoader__.load({
276
276
  cur.card ? Button({ variant: "ghost", onClick: exportCard }, "导出") : null,
277
277
  h("input", { ref: fileRef, type: "file", accept: ".json,.png,application/json,image/png", hidden: true, onChange: pickFile })),
278
278
  Row({ title: "正在聊的也马上换", desc: freshOn ? "下一条消息起开新会话,旧聊天的口吻不带过来;记忆和关系都还在" : "沿用旧会话:新人格下一轮生效,但口吻可能要几轮才转过来" },
279
- h(Switch, { label: "正在聊的也马上换", checked: freshOn, onChange: toggleFresh }))),
280
- Group({ title: "人格模板" },
279
+ h(Switch, { label: "正在聊的也马上换", checked: freshOn, onChange: toggleFresh }))) : null,
280
+ section === "templates" ? Group({ title: "人格模板" },
281
281
  ...(data.templates ?? []).map((t) => h(Template, {
282
282
  key: t.id, t, h, ui, open: open === t.id, current: cur.template === t.id && cur.source === "persona.json",
283
283
  onOpen: () => setOpen(open === t.id ? null : t.id),
284
284
  onApply: () => applyTemplate(t),
285
285
  onEdit: () => setEditing({ ...t.card, data: { ...t.card.data, extensions: { ...t.card.data.extensions, fish_soul: { ...t.card.data.extensions?.fish_soul, template: t.id } } } })
286
- }))),
287
- h(Growth, { h, ui, growth: data.growth, post: growthPost, busy }),
288
- h(WorldInfo, { api, notify, ui }));
286
+ }))) : null,
287
+ section === "growth" ? h(Growth, { h, ui, growth: data.growth, post: growthPost, busy }) : null,
288
+ section === "worldinfo" ? h(WorldInfo, { api, notify, ui }) : null);
289
289
  }
290
290
 
291
291
  return { PersonaPane };
@@ -443,9 +443,9 @@ window.__ModuleLoader__.load({
443
443
  confirmNode);
444
444
  }
445
445
 
446
- function InspectorPane(props) {
446
+ function InspectorPane({ section = "memories", ...props }) {
447
447
  const { api, notify, ui } = props;
448
- const { h, Tag, Empty, Item, Muted, Group, Input, Button, useConfirm } = ui;
448
+ const { h, Tag, Empty, Item, Muted, Group, Row, Input, Button, useConfirm } = ui;
449
449
  const [owner, setOwner] = react.useState("all");
450
450
  const [q, setQ] = react.useState("");
451
451
  const [qApplied, setQApplied] = react.useState("");
@@ -498,16 +498,22 @@ window.__ModuleLoader__.load({
498
498
  if (r?.ok) { setAdding(""); notify("已记下"); load(); } else notify(r?.error ?? "添加失败");
499
499
  }
500
500
  return h(react.Fragment, null,
501
- h("section", { className: "fs-group" },
502
- h("div", { className: "fs-group-head" },
503
- h("div", { className: "fs-pills", role: "group", "aria-label": "按人筛选" }, pill("all", "全部"),
504
- ...persons.map((p) => pill(p.ownerId, p.ownerId === "self" ? "她自己" : p.displayName, p.ownerId === "self" ? undefined : `${stageLabel(p.relationStage)} · ${p.memoryCount} 条`))),
505
- Tag(String(memories.length), "outline"))),
506
- owner !== "all" && owner !== "self" ? h(PersonCard, { key: owner, ownerId: owner, ui, api, notify, onChanged: load }) : null,
507
- owner !== "all" && owner !== "self" ? h(MemoryDebug, { key: `dbg-${owner}`, ownerId: owner, ui, api, notify }) : null,
508
- Group({ title: owner === "all" ? "最近的记忆" : "记忆", aside: h("span", { className: "fs-group-aside" },
501
+ section === "people" ? Group({ title: "人物与关系" },
502
+ Row({ title: "选择人物" }, ui.Select({ value: owner, "aria-label": "选择人物", onChange: (e) => setOwner(e.target.value) },
503
+ h("option", { value: "all" }, "请选择"),
504
+ ...persons.filter((p) => p.ownerId !== "self").map((p) => h("option", { key: p.ownerId, value: p.ownerId }, `${p.displayName} · ${p.memoryCount} 条`)))),
505
+ owner !== "all" && owner !== "self" ? h(PersonCard, { key: owner, ownerId: owner, ui, api, notify, onChanged: load }) : Empty("选一个人,查看关系、称呼和印象")) : null,
506
+ section === "recall-test" ? Group({ title: "检索测试" },
507
+ Row({ title: "选择人物" }, ui.Select({ value: owner, "aria-label": "测试哪个人", onChange: (e) => setOwner(e.target.value) },
508
+ h("option", { value: "all" }, "请选择"),
509
+ ...persons.filter((p) => p.ownerId !== "self").map((p) => h("option", { key: p.ownerId, value: p.ownerId }, p.displayName)))),
510
+ owner !== "all" && owner !== "self" ? h(MemoryDebug, { key: `dbg-${owner}`, ownerId: owner, ui, api, notify }) : Empty("选一个人,输入一句话试试会想起什么")) : null,
511
+ section === "memories" ? h(react.Fragment, null,
512
+ Group({ title: "记忆内容", aside: h("span", { className: "fs-group-aside" },
509
513
  data.matchedTotal != null && data.matchedTotal > memories.length ? Muted(`显示 ${memories.length} / ${data.matchedTotal} 条`) : null,
510
514
  Button({ variant: bulk ? "primary" : "ghost", onClick: () => setBulk(!bulk) }, bulk ? "完成" : "批量")) },
515
+ h("div", { className: "fs-pills", role: "group", "aria-label": "按人筛选" }, pill("all", "全部"),
516
+ ...persons.map((p) => pill(p.ownerId, p.ownerId === "self" ? "她自己" : p.displayName, p.ownerId === "self" ? undefined : `${stageLabel(p.relationStage)} · ${p.memoryCount} 条`))),
511
517
  h("div", { className: "fs-row" },
512
518
  Input({ value: q, className: "wide", placeholder: "搜记忆内容(回车)", "aria-label": "搜记忆内容", onChange: (e) => setQ(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") setQApplied(q.trim()); }, onBlur: () => setQApplied(q.trim()) }),
513
519
  ui.Select({ value: source, "aria-label": "来源", onChange: (e) => setSource(e.target.value) }, ...SOURCES.map(([v, l]) => h("option", { key: v, value: v }, l)))),
@@ -518,10 +524,10 @@ window.__ModuleLoader__.load({
518
524
  h("div", { className: "fs-list" }, memories.length === 0 ? Empty("还没有记忆")
519
525
  : memories.map((m) => h(MemoryItem, { key: m.id, m, ui, api, notify, refresh: load, curGen: data.currentGenerationId ?? 1, selectable: bulk, selected: sel.has(m.id), onSelect })))),
520
526
  pendings.length === 0 ? null : Group({ title: "待办", aside: Tag(String(pendings.length), "outline") },
521
- h("div", { className: "fs-list" }, pendings.map((p) => Item({ key: p.id, lead: Muted(props.fmtDueAt(p.dueAt), { className: "fs-unit num" }), text: p.reason, meta: [p.channelId ?? ""], onRemove: () => dropPending(p.id) })))),
522
- h(WeeklyPanel, { ui, api, notify, onDone: load }),
523
- h(StoragePanel, { ui, api, notify }),
524
- h(ImportBackup, { ui, api, notify, relTime: props.relTime, onDone: load }),
527
+ h("div", { className: "fs-list" }, pendings.map((p) => Item({ key: p.id, lead: Muted(props.fmtDueAt(p.dueAt), { className: "fs-unit num" }), text: p.reason, meta: [p.channelId ?? ""], onRemove: () => dropPending(p.id) }))))) : null,
528
+ section === "weekly" ? h(WeeklyPanel, { ui, api, notify, onDone: load }) : null,
529
+ section === "archives" ? h(StoragePanel, { ui, api, notify }) : null,
530
+ section === "backups" ? h(ImportBackup, { ui, api, notify, relTime: props.relTime, onDone: load }) : null,
525
531
  confirmNode);
526
532
  }
527
533
 
@@ -671,7 +677,7 @@ window.__ModuleLoader__.load({
671
677
  return h(react.Fragment, null, groups.map((g) => h(SlangGroupCard, { key: `${g.channelId}-${g.learnedAt ?? 0}-${(g.terms ?? []).length}-${(g.expressions ?? []).length}`, g, api, notify, relTime, ui, onChanged: load })));
672
678
  }
673
679
 
674
- function ActivityPane({ api, apiBase, notify, relTime, ui }) {
680
+ function ActivityPane({ api, apiBase, notify, relTime, ui, section = "activity" }) {
675
681
  const { h, Group, Tag, Empty, Button, Muted } = ui;
676
682
  const [data, load] = usePoll(api, "/activity", 3000);
677
683
  if (!data) return null;
@@ -682,6 +688,7 @@ window.__ModuleLoader__.load({
682
688
  }
683
689
  const decisions = data.decisions ?? [];
684
690
  return h(react.Fragment, null,
691
+ section === "activity" ? h(react.Fragment, null,
685
692
  (data.paused ?? []).length === 0 ? null : Group({ title: "暂停中" },
686
693
  h("div", { className: "fs-list" }, data.paused.map((p) => h("div", { className: "fs-item", key: p.channelId },
687
694
  h("span", { className: "fs-item-main" }, p.title),
@@ -718,9 +725,9 @@ window.__ModuleLoader__.load({
718
725
  return h("details", { className: "fs-step", key: i },
719
726
  h("summary", { className: "fs-step-head" }, ...head, ui.Icon("chevron", 12)),
720
727
  h("div", { className: "fs-step-body" }, h("ul", null, x.outdated.map((o) => h("li", { key: o.id }, `#${o.id}「${o.content}」→ #${o.by}:${o.reason}`)))));
721
- }))),
722
- h(StickerShelf, { api, apiBase, notify, relTime, ui }),
723
- h(SlangShelf, { api, notify, relTime, ui }));
728
+ })))) : null,
729
+ section === "stickers" ? h(StickerShelf, { api, apiBase, notify, relTime, ui }) : null,
730
+ section === "slang" ? h(SlangShelf, { api, notify, relTime, ui }) : null);
724
731
  }
725
732
 
726
733
  return { InspectorPane, ActivityPane };
@@ -2,7 +2,8 @@
2
2
  * douyin-social.js —— 鱼在抖音自己发图文作品、回作品下的评论(v0.10,默认都关,尽力而为)。
3
3
  *
4
4
  * 抖音网页版没有能直接调的接口,全靠专用浏览器里点页面:
5
- * - 发图文:把她写的话在浏览器里画成一张文字卡片(canvas),到创作者中心「发布图文」上传、填标题和描述、点发布。
5
+ * - 发图文:把她写的话在浏览器里画成一张文字卡片(canvas),到创作者中心「发布图文」上传、填标题和描述、配 BGM、点发布。
6
+ * BGM 不写死歌单:到编辑页的音乐面板抓「推荐」里的歌,交给模型挑一首,挑不出来就不配。
6
7
  * 创作者中心(creator.douyin.com)可能要单独登录一次;页面改版就会发不出去(设置页能看到原因)。
7
8
  * - 回评论:每 20 分钟打开「我的」作品页,看最近 3 个作品的评论区,对新评论点「回复」、打字、发送。
8
9
  * 第一次只记下已有的评论、不回旧的;"作者"自己的不回;每小时最多 perHour 条。
@@ -42,20 +43,27 @@ export function noteTitle(text) {
42
43
  /** 心情 → cardJs themes 的下标(顺序:米黄暖 / 浅蓝丧 / 淡紫梦 / 浅绿清 / 粉甜)。 */
43
44
  export const MOOD_THEME = { warm: 0, blue: 1, dream: 2, fresh: 3, pink: 4 };
44
45
  const MOOD_LIST = 'warm 暖/治愈、blue 丧/低落、dream 梦幻/恍惚、fresh 清新/日常、pink 甜/开心';
45
- /** 抖音 2026 图文号高频 BGM 池(都是音乐库搜得到的热歌)。 */
46
- const MUSIC_POOL = ['月亮照山川', '晚风心里吹', '若月亮没来', '会开花的云', '下一个天亮', '落在生命里的光', '一路生花', '离别开出花', '茶花开了', '无名的人', '远山少年', '小美满', '人间烟火', '小村庄月弯弯', '亲爱的你啊'];
47
46
 
48
- /** 让模型给文案配心情 + BGM,返回 {theme, music};任何一步出问题都安全兜底,不耽误发布。 */
49
- export function pickMusicTheme(raw, random = Math.random) {
50
- const fallback = { theme: Math.floor(random() * 5), music: '' };
47
+ /** 让模型给文案配心情,返回 {theme, reason};解析不出来就随机一个主题,不耽误发布。 */
48
+ export function pickMoodTheme(raw, random = Math.random) {
49
+ const fallback = { theme: Math.floor(random() * 5), reason: '' };
51
50
  const m = String(raw ?? '').match(/\{[^{}]*\}/s);
52
51
  if (!m) return fallback;
53
52
  let j;
54
53
  try { j = JSON.parse(m[0]); } catch { return fallback; }
55
54
  const mood = String(j?.mood ?? '').toLowerCase().trim();
56
55
  const theme = Object.hasOwn(MOOD_THEME, mood) ? MOOD_THEME[mood] : fallback.theme;
57
- const music = MUSIC_POOL.find((s) => s === String(j?.music ?? '').trim()) ?? '';
58
- return { theme, music, reason: String(j?.reason ?? '').trim().slice(0, 40) };
56
+ return { theme, reason: String(j?.reason ?? '').trim().slice(0, 40) };
57
+ }
58
+
59
+ /** 从抖音推荐的歌里挑一首(只能挑列表里的,模型编的名字不算);挑不出来返回 ''。 */
60
+ export function pickRecommendedSong(raw, songs) {
61
+ const list = (Array.isArray(songs) ? songs : []).map((s) => String(s ?? '').trim()).filter(Boolean);
62
+ if (!list.length) return '';
63
+ const out = String(raw ?? '').trim().replace(/^["'「『\s]+|["'」』\s]+$/g, '');
64
+ if (!out) return '';
65
+ if (list.includes(out)) return out;
66
+ return [...list].sort((a, b) => b.length - a.length).find((s) => out.includes(s)) ?? '';
59
67
  }
60
68
 
61
69
  /** 评论去重用的 id:作品 + 谁 + 说了什么。 */
@@ -125,6 +133,64 @@ const WORKS_JS = String.raw`(() => {
125
133
 
126
134
  const COMMENT_STATE_JS = `(() => ({ box: Boolean(document.querySelector('${SEL.input}')), items: document.querySelectorAll('${SEL.item}').length, btn: Boolean(document.querySelector('${SEL.commentBtn}')), url: location.href }))()`;
127
135
 
136
+ /** 编辑页上的「选择音乐」入口(页面有多个同名节点,取最后一个——真正可点的那颗)。 */
137
+ const MUSIC_ENTRY_JS = String.raw`(() => {
138
+ const els = [...document.querySelectorAll('div, span, button')].filter((x) => x.children.length === 0 && /选择音乐/.test((x.innerText || '').trim()));
139
+ const el = els[els.length - 1];
140
+ if (!el) return null;
141
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
142
+ const r = el.getBoundingClientRect();
143
+ return r.width && r.height ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } : null;
144
+ })()`;
145
+
146
+ /** 音乐面板开了没:有搜索框,或者已经列出带「万人使用」的歌。 */
147
+ const MUSIC_OPEN_JS = String.raw`(() => {
148
+ if (document.querySelector('input[placeholder*="搜索"]')) return true;
149
+ return /万人使用/.test(document.body.innerText || '');
150
+ })()`;
151
+
152
+ /** 音乐面板的「推荐」页签(只在面板里点,免得点到页面上别的「推荐」)。 */
153
+ const MUSIC_TAB_JS = String.raw`(() => {
154
+ const inPanel = (el) => { let p = el; for (let i = 0; i < 6 && p; i++, p = p.parentElement) if (/万人使用/.test(p.innerText || '')) return true; return false; };
155
+ const tab = [...document.querySelectorAll('div, span, button')].find((x) => x.children.length === 0 && (x.innerText || '').trim() === '推荐' && inPanel(x));
156
+ if (!tab) return false;
157
+ tab.click();
158
+ return true;
159
+ })()`;
160
+
161
+ /** 音乐面板里的歌名:每行第一行是歌名,下面是「歌手 · 时长」「xxx万人使用」。 */
162
+ const MUSIC_ROWS_JS = String.raw`(() => {
163
+ const out = [];
164
+ const push = (t) => {
165
+ const s = String(t || '').trim();
166
+ if (s && !/^\d+$/.test(s) && out.length < 10 && !out.includes(s)) out.push(s);
167
+ };
168
+ const rows = document.querySelectorAll('[class*="music-item"],[class*="song-item"],[class*="MusicItem"]');
169
+ if (rows.length) {
170
+ for (const row of rows) {
171
+ if (out.length >= 10) break;
172
+ const lines = (row.innerText || '').split('\n').map((s) => s.trim()).filter(Boolean);
173
+ if (lines.length) push(lines[0]);
174
+ }
175
+ return out;
176
+ }
177
+ // 兜底:找写着「xxx万人使用」的叶子,往上找到带歌名的那一行
178
+ for (const el of document.querySelectorAll('*')) {
179
+ if (out.length >= 10) break;
180
+ if (el.children.length > 0) continue;
181
+ if (!/万人使用/.test(el.innerText || '')) continue;
182
+ let row = el.parentElement;
183
+ for (let j = 0; j < 4 && row; j++, row = row.parentElement) {
184
+ const text = (row.innerText || '').trim();
185
+ if (text.length > 10 && text.length < 200 && /万人使用/.test(text)) {
186
+ const lines = text.split('\n').map((s) => s.trim()).filter(Boolean);
187
+ if (lines.length >= 2) { push(lines[0]); break; }
188
+ }
189
+ }
190
+ }
191
+ return out;
192
+ })()`;
193
+
128
194
  /** 评论区里的一级评论:作者 / 内容 / 是不是作者本人 / 能不能回。 */
129
195
  const LIST_COMMENTS_JS = `(() => [...document.querySelectorAll('${SEL.item}')].map((el, i) => {
130
196
  const lines = (el.innerText || '').split('\\n').map((s) => s.trim()).filter(Boolean);
@@ -150,22 +216,54 @@ async function waitFor(fn, { tries = 10, gapMs = 1000 } = {}) {
150
216
  return null;
151
217
  }
152
218
 
219
+ /** 点编辑页上的「选择音乐」入口(真点,不是 JS click),返回点了没。 */
220
+ async function clickMusicEntry(br) {
221
+ const sel = await br.eval(MUSIC_ENTRY_JS).catch(() => null);
222
+ if (!sel) return false;
223
+ await realClick(br, sel.x, sel.y);
224
+ await sleep(1200);
225
+ return true;
226
+ }
227
+
228
+ /** 打开音乐面板,返回面板开了没。 */
229
+ async function openMusicPanel(br) {
230
+ if (!await clickMusicEntry(br)) return false;
231
+ const opened = await waitFor(() => br.eval(MUSIC_OPEN_JS).catch(() => false), { tries: 6, gapMs: 800 });
232
+ return opened === true;
233
+ }
234
+
235
+ /** 编辑页上打开音乐面板,抓「推荐」里前 10 首歌名,再把面板关掉(不选歌)。抓不到返回 []。 */
236
+ async function grabRecommendedMusic(br) {
237
+ if (!await openMusicPanel(br)) return [];
238
+ try {
239
+ await br.eval(MUSIC_TAB_JS).catch(() => false);
240
+ await sleep(800);
241
+ const songs = await br.eval(MUSIC_ROWS_JS).catch(() => []);
242
+ return (Array.isArray(songs) ? songs : []).map((s) => String(s ?? '').trim()).filter(Boolean).slice(0, 10);
243
+ } finally {
244
+ await shutMusicPanel(br);
245
+ }
246
+ }
247
+
248
+ /** 把音乐面板关掉:先 Esc,再点「完成 / 收起 / 关闭 / 取消」。 */
249
+ async function shutMusicPanel(br) {
250
+ for (const type of ['keyDown', 'keyUp']) {
251
+ await br.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }, br.sessionId).catch(() => {});
252
+ }
253
+ await sleep(400);
254
+ await br.eval(String.raw`(() => {
255
+ const el = [...document.querySelectorAll('div, span, button')].find((x) => x.children.length === 0 && /^(完成|收起|关闭|取消)$/.test((x.innerText || '').trim()));
256
+ if (el) el.click();
257
+ return true;
258
+ })()`).catch(() => false);
259
+ await sleep(600);
260
+ }
261
+
153
262
  /** 图文编辑页里配 BGM:点「选择音乐」→ 搜歌名 → 用第一首。整个尽力而为,失败返回 false,绝不拦发布。 */
154
263
  async function pickNoteMusic(br, music) {
155
264
  if (!music) return false;
156
265
  try {
157
- const sel = await br.eval(String.raw`(() => {
158
- // 页面有多个「选择音乐」文本节点(左边灰化标签区、右边真正可点的入口)。取最后一个——真正可点的那颗。
159
- const els = [...document.querySelectorAll('div, span, button')].filter((x) => x.children.length === 0 && /选择音乐/.test((x.innerText || '').trim()));
160
- const el = els[els.length - 1];
161
- if (!el) return null;
162
- el.scrollIntoView({ block: 'center', behavior: 'instant' });
163
- const r = el.getBoundingClientRect();
164
- return r.width && r.height ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } : null;
165
- })()`).catch(() => null);
166
- if (!sel) return false;
167
- await realClick(br, sel.x, sel.y);
168
- await sleep(1200);
266
+ if (!await clickMusicEntry(br)) return false;
169
267
  const opened = await br.eval('Boolean(document.querySelector(\'input[placeholder*="搜索"]\'))').catch(() => false);
170
268
  if (!opened) return false;
171
269
  const box = await waitFor(() => br.eval('Boolean(document.querySelector(\'input[placeholder*="搜索"]\'))'), { tries: 6, gapMs: 800 });
@@ -239,8 +337,8 @@ function douyinSocialDriver(host) {
239
337
  return typeof out === 'string' && out !== '' ? Buffer.from(out, 'base64') : null;
240
338
  },
241
339
 
242
- /** 创作者中心发一条图文。返回 {ok, code?, error?, musicPicked?}。music 非空时发布前尽量配 BGM,失败不影响发布。 */
243
- async publishNote({ title, text, file, music }) {
340
+ /** 创作者中心:传图 + 填标题描述,停在编辑页。返回 {ok, code?, error?}。 */
341
+ async prepareNote({ title, text, file }) {
244
342
  const br = b();
245
343
  await br.goto(CREATOR_UPLOAD_URL, { settleMs: 8000 });
246
344
  const url = String(await br.eval('location.href').catch(() => '') ?? '');
@@ -277,6 +375,17 @@ function douyinSocialDriver(host) {
277
375
  await sleep(1000);
278
376
  // 等图片传完("上传中"没了)
279
377
  await waitFor(async () => !/上传中|正在上传/.test(String(await br.eval('document.body.innerText').catch(() => '') ?? '')), { tries: 30 });
378
+ return { ok: true };
379
+ },
380
+
381
+ /** 编辑页上打开音乐面板,抓「推荐」里前 10 首歌名,关掉面板。抓不到返回 []。 */
382
+ async listRecommendedMusic() {
383
+ return await grabRecommendedMusic(b());
384
+ },
385
+
386
+ /** 编辑页上:music 非空就配 BGM,再点发布。返回 {ok, code?, error?, musicPicked?}。 */
387
+ async publishNote({ music }) {
388
+ const br = b();
280
389
  const musicPicked = await pickNoteMusic(br, String(music ?? ''));
281
390
  const clicked = await br.eval(String.raw`(() => {
282
391
  const btns = [...document.querySelectorAll('button')].filter((x) => (x.innerText || '').trim() === '发布' && !x.disabled);
@@ -422,7 +531,7 @@ export class DouyinSocial extends SocialBase {
422
531
  }
423
532
 
424
533
  postStyle() {
425
- return '你写的话会印在一张纯色文字卡片上,同时当作品描述。像真人随手发的那种:短、碎、口语,一般 10~60 字,可以分两三行。偶尔(不是每条)可以带个轻量的钩子:一个小问句、一句替大家说出心声的吐槽,像跟朋友聊天,别硬凑,也别每条都来。不要"记录生活"这种四字口号,不要文艺腔。';
534
+ return '你写的话会印在一张纯色文字卡片上,同时当作品描述。像真人随手发的那种:短、碎、口语,一般 10~60 字,可以分两三行。偶尔(不是每条)可以带个轻量的钩子:一个小问句、一句此刻的小感受,像跟朋友聊天,别硬凑,也别每条都来。不要"记录生活"这种四字口号,不要文艺腔。';
426
535
  }
427
536
 
428
537
  whoIs(it, master) {
@@ -438,27 +547,47 @@ export class DouyinSocial extends SocialBase {
438
547
 
439
548
  async publish(text) {
440
549
  const parts = this.clock.getRoleDateParts();
441
- // 配心情 + BGM:LLM 挑不出来就退回随机主题、不配音乐,不影响发布
442
- let pick = { theme: Math.floor(this.random() * 5), music: '' };
550
+ // 心情只管卡片配色:挑不出来就随机一个
551
+ let theme = Math.floor(this.random() * 5);
443
552
  try {
444
- const raw = String((await this.callLlm(`给这条抖音图文配「心情」和「背景音乐」。\n\n图文正文:\n${text}\n\n心情只能是这 5 个之一:${MOOD_LIST}。\n背景音乐从这个池子里挑一首(歌名原样照抄),文案不适合配歌就选空串:${MUSIC_POOL.join(' / ')}。\n只输出一行 JSON:{"mood":"warm|blue|dream|fresh|pink","music":"歌名或空串","reason":"一句话为什么"}`)) ?? '').trim();
445
- pick = pickMusicTheme(raw, this.random);
446
- if (pick.music) this.note('post', { title: this.label, reason: `配了首《${pick.music}》${pick.reason ? `(${pick.reason})` : ''}` });
553
+ const raw = String((await this.callLlm(`给这条抖音图文配个「心情」。\n\n图文正文:\n${text}\n\n心情只能是这 5 个之一:${MOOD_LIST}。\n只输出一行 JSON:{"mood":"warm|blue|dream|fresh|pink","reason":"一句话为什么"}`)) ?? '').trim();
554
+ theme = pickMoodTheme(raw, this.random).theme;
447
555
  } catch { /* 挑不出来就算了 */ }
448
- const png = await this.host.exclusive('画卡片', () => this.driver.renderCard(text, parts.dateStr.replace(/-/g, '.'), pick.theme), { maxMs: 30_000 });
556
+ const png = await this.host.exclusive('画卡片', () => this.driver.renderCard(text, parts.dateStr.replace(/-/g, '.'), theme), { maxMs: 30_000 });
449
557
  if (!png) throw new Error('卡片没画出来');
450
558
  mkdirSync(this.cardDir, { recursive: true });
451
559
  const file = join(this.cardDir, `card-${Date.now()}.png`);
452
560
  writeFileSync(file, png);
453
- const r = await this.host.exclusive('发抖音图文', () => this.driver.publishNote({ title: noteTitle(text), text, file, music: pick.music }), { maxMs: 4 * 60_000 });
561
+ // 传图 → 抓推荐歌 → 挑歌 → 配 BGM → 发布:整段在浏览器锁里连着做完(页面不能被别的事换掉)
562
+ let song = '';
563
+ const r = await this.host.exclusive('发抖音图文', async () => {
564
+ const pre = await this.driver.prepareNote({ title: noteTitle(text), text, file });
565
+ if (!pre?.ok) return pre;
566
+ let songs = [];
567
+ try { songs = await this.driver.listRecommendedMusic(); } catch { songs = []; }
568
+ song = await this.chooseMusic(text, songs).catch(() => '');
569
+ return await this.driver.publishNote({ music: song });
570
+ }, { maxMs: 5 * 60_000 });
454
571
  if (r?.code === 'NEEDS_VERIFY') this.blocked('发作品');
455
572
  if (r?.code === 'NOT_LOGGED_IN') this.notify('douyin.creator', '想在抖音发图文,但创作者中心没登录:在抖音浏览器里打开 creator.douyin.com 登录一次就好');
456
573
  if (!r?.ok) throw new Error(r?.error ?? '没发出去');
457
- if (pick.music && r?.musicPicked === false) this.note('post', { title: this.label, reason: `《${pick.music}》没配上(页面没找到或改版了),图发出去了但没 BGM` });
574
+ if (song && r?.musicPicked === false) this.note('post', { title: this.label, reason: `《${song}》没配上(页面没找到或改版了),图发出去了但没 BGM` });
458
575
  this.notify('douyin.creator', null);
459
576
  return '';
460
577
  }
461
578
 
579
+ /** 从抖音推荐的歌里挑一首配这条图文;挑不出来就不配,不影响发布。 */
580
+ async chooseMusic(text, songs) {
581
+ const list = (Array.isArray(songs) ? songs : []).map((s) => String(s ?? '').trim()).filter(Boolean).slice(0, 10);
582
+ if (!list.length) return '';
583
+ try {
584
+ const raw = String((await this.callLlm(`给这条抖音图文配背景音乐。从下面这些抖音推荐歌里挑一首最适合的(歌名原样照抄),不适合就回空串。\n\n图文正文:\n${text}\n\n推荐歌:\n${list.map((s) => `- ${s}`).join('\n')}\n\n只输出歌名,不要别的。`)) ?? '').trim();
585
+ const picked = pickRecommendedSong(raw, list);
586
+ if (picked) this.note('post', { title: this.label, reason: `配了首《${picked}》` });
587
+ return picked;
588
+ } catch { return ''; }
589
+ }
590
+
462
591
  /** 看最近几个作品的评论区,挨个决定回不回。 */
463
592
  async pollComments() {
464
593
  const c = this.cfg();
package/lib/routes-v07.js CHANGED
@@ -67,8 +67,8 @@ export function onboardingSteps(deps) {
67
67
  { id: 'qq', title: '接上 QQ(SnowLuma)', done: channel?.status?.().connected === true, optional: false, tab: 'qq' },
68
68
  { id: 'master', title: '填主人 QQ 号(她才知道谁是你)', done: String(cfg.masterQqUin ?? '') !== '', optional: false, tab: 'qq' },
69
69
  { id: 'retrieval', title: '配好记忆检索(向量),想起事情更准', done: str(cfg.embedding?.apiKey) !== '', optional: true, tab: 'retrieval' },
70
- { id: 'import', title: '导入以前的聊天记录,让她认识老朋友', done: imported, optional: true, tab: 'inspector' },
71
- { id: 'voice', title: '打开语音(能听 QQ 语音,偶尔发语音)', done: voice?.ready?.() === true, optional: true, tab: 'models' }
70
+ { id: 'import', title: '导入以前的聊天记录,让她认识老朋友', done: imported, optional: true, tab: 'backups' },
71
+ { id: 'voice', title: '打开语音(能听 QQ 语音,偶尔发语音)', done: voice?.ready?.() === true, optional: true, tab: 'voice' }
72
72
  ];
73
73
  }
74
74
 
package/lib/social.js CHANGED
@@ -146,17 +146,17 @@ export class SocialBase {
146
146
  return '像真人发的那种动态,口语、有网感,像跟朋友发消息,不要端着。';
147
147
  }
148
148
 
149
- /** 每次发文随机挑一个「具体行为指令」,比「随手发一句」发散得多。 */
149
+ /** 每次发文随机挑一个「具体行为指令」,比「随手发一句」发散得多。方向都是日常分享,不写观后感。 */
150
150
  postTopic() {
151
151
  const topics = [
152
- '用你自己的风格冷吐槽一下刚看到的社会现象或互联网怪象',
153
152
  '分享此刻的碎片瞬间:刚刚在干嘛、看到了什么、一个小想法',
154
153
  '像记日记一样写一两句今天的小事,不用有主题',
155
- '毒舌点评一下大家都在讨论的东西,别当真,就图一乐',
156
154
  '发一句只有此刻才有的感慨(时间、天气、此刻的心境)',
157
- '说说今天遇到的一件让你想笑/想吐槽/无语的小事',
158
- '发一句你此刻的真实想法,不用多,一两行就行',
159
- '用「嘴替」的视角替大家说句心里话(丧的、累的、想躺的都行)'
155
+ '刚吃到或喝到的:一样此刻嘴里的东西,好吃难吃都算',
156
+ '此刻身边的画面:窗外、路上、房间里现在是什么样',
157
+ '想做但还没做的小事:写一件最近一直惦记、但一直没去做的事',
158
+ '今天遇到的一件让你想笑或想叹气的小事',
159
+ '今天一个小小的发现:某个不起眼的细节,突然觉得挺有意思'
160
160
  ];
161
161
  return topics[Math.floor(this.random() * topics.length)];
162
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.15.7",
3
+ "version": "0.15.8",
4
4
  "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.5)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",