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 +1 -1
- package/lib/behavior.js +1 -5
- package/lib/client.behavior.js +6 -6
- package/lib/client.js +62 -62
- package/lib/client.models.js +6 -2
- package/lib/client.persona.js +7 -7
- package/lib/client.soul.js +25 -18
- package/lib/douyin-social.js +160 -31
- package/lib/routes-v07.js +2 -2
- package/lib/social.js +6 -6
- package/package.json +1 -1
package/README.md
CHANGED
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
|
-
|
|
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' +
|
package/lib/client.behavior.js
CHANGED
|
@@ -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
|
-
//
|
|
156
|
-
function GroupedTabs({
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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: "习",
|
|
767
|
-
const TABS = [
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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("
|
|
849
|
+
const [tab, setTab] = react.useState("memory");
|
|
850
|
+
const [subtab, setSubtab] = react.useState("retrieval");
|
|
863
851
|
const hasInteracted = react.useRef(false);
|
|
864
|
-
const
|
|
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" ? "
|
|
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 =
|
|
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,
|
|
908
|
+
const timer = setInterval(fn, subtab === "retrieval" ? 2000 : 3000);
|
|
910
909
|
return () => clearInterval(timer);
|
|
911
|
-
}, [
|
|
910
|
+
}, [subtab]);
|
|
912
911
|
// 虚拟时钟走秒(纯展示)
|
|
913
912
|
react.useEffect(() => {
|
|
914
|
-
if (
|
|
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 (
|
|
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:
|
|
939
|
-
:
|
|
940
|
-
:
|
|
941
|
-
:
|
|
942
|
-
:
|
|
943
|
-
:
|
|
944
|
-
:
|
|
945
|
-
:
|
|
946
|
-
:
|
|
947
|
-
:
|
|
948
|
-
:
|
|
949
|
-
:
|
|
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
|
|
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, {
|
|
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: () =>
|
|
961
|
-
h("div", { className: "fs-pane", role: "tabpanel", id: "fs-tabpanel", "aria-labelledby": `fs-tab-${
|
|
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
|
|
package/lib/client.models.js
CHANGED
|
@@ -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,
|
|
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 };
|
package/lib/client.persona.js
CHANGED
|
@@ -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 };
|
package/lib/client.soul.js
CHANGED
|
@@ -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
|
-
|
|
502
|
-
|
|
503
|
-
h("
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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 };
|
package/lib/douyin-social.js
CHANGED
|
@@ -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
|
-
/**
|
|
49
|
-
export function
|
|
50
|
-
const fallback = { theme: Math.floor(random() * 5),
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
243
|
-
async
|
|
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
|
-
//
|
|
442
|
-
let
|
|
550
|
+
// 心情只管卡片配色:挑不出来就随机一个
|
|
551
|
+
let theme = Math.floor(this.random() * 5);
|
|
443
552
|
try {
|
|
444
|
-
const raw = String((await this.callLlm(
|
|
445
|
-
|
|
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, '.'),
|
|
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
|
-
|
|
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 (
|
|
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: '
|
|
71
|
-
{ id: 'voice', title: '打开语音(能听 QQ 语音,偶尔发语音)', done: voice?.ready?.() === true, optional: true, tab: '
|
|
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.
|
|
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",
|