dsh-hive-queen 0.0.0-stage → 0.1.0
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 +53 -2
- package/cordis.patch.yml +5 -0
- package/lib/attachments.js +187 -0
- package/lib/avatar.js +86 -0
- package/lib/backup.js +248 -0
- package/lib/behavior.js +617 -0
- package/lib/browse.js +577 -0
- package/lib/browsers.js +218 -0
- package/lib/channels/bilibili.js +1044 -0
- package/lib/channels/douyin.js +890 -0
- package/lib/channels/onebot.js +207 -0
- package/lib/channels/qrcode.js +260 -0
- package/lib/channels/snowluma.js +272 -0
- package/lib/channels/vendor/douyin-bridge/LICENSE +21 -0
- package/lib/channels/vendor/douyin-bridge/README.md +42 -0
- package/lib/channels/vendor/douyin-bridge/browser.mjs +703 -0
- package/lib/channels/vendor/douyin-bridge/douyin-api.mjs +537 -0
- package/lib/channels/vendor/douyin-bridge/douyin-detail.mjs +151 -0
- package/lib/channels/vendor/douyin-bridge/douyin-dm.mjs +1223 -0
- package/lib/channels/vendor/douyin-bridge/douyin-panel.mjs +720 -0
- package/lib/channels/vendor/douyin-bridge/douyin-util.mjs +58 -0
- package/lib/channels/vendor/douyin-bridge/media.mjs +365 -0
- package/lib/channels/vendor/douyin-bridge/pb.mjs +306 -0
- package/lib/channels/vendor/douyin-bridge/selectors.mjs +313 -0
- package/lib/channels/vendor/qq-bridge.js +191 -0
- package/lib/channels/wechat.js +194 -0
- package/lib/chat-archive.js +18 -0
- package/lib/client.behavior.js +179 -0
- package/lib/client.dashboard.js +106 -0
- package/lib/client.douyin.js +193 -0
- package/lib/client.extra.js +128 -0
- package/lib/client.js +982 -0
- package/lib/client.models.js +213 -0
- package/lib/client.persona.js +291 -0
- package/lib/client.soul.js +703 -0
- package/lib/client.wechat.js +52 -0
- package/lib/clock.js +42 -0
- package/lib/config-platforms.js +91 -0
- package/lib/config-schema.js +188 -0
- package/lib/db.js +522 -0
- package/lib/desktop-client.js +96 -0
- package/lib/douyin-browse.js +382 -0
- package/lib/douyin-social.js +530 -0
- package/lib/emotion.js +395 -0
- package/lib/flyops.js +85 -0
- package/lib/gateway-behavior.js +175 -0
- package/lib/gateway-media.js +421 -0
- package/lib/gateway-util.js +150 -0
- package/lib/gateway.js +1297 -0
- package/lib/growth.js +128 -0
- package/lib/history-import.js +788 -0
- package/lib/hostdeps.mjs +172 -0
- package/lib/ilink.js +392 -0
- package/lib/imageconv.js +183 -0
- package/lib/importer.js +77 -0
- package/lib/index.js +1148 -0
- package/lib/life-sim.js +851 -0
- package/lib/life.js +666 -0
- package/lib/live.js +62 -0
- package/lib/llm.js +318 -0
- package/lib/maibot-extras.js +227 -0
- package/lib/memory-bulk.js +89 -0
- package/lib/moa.js +25 -0
- package/lib/outbox.js +134 -0
- package/lib/panel.css +441 -0
- package/lib/permissions.js +42 -0
- package/lib/persona-templates.js +347 -0
- package/lib/persona.js +265 -0
- package/lib/retrieval.js +467 -0
- package/lib/routes-douyin.js +210 -0
- package/lib/routes-persona.js +114 -0
- package/lib/routes-soul.js +409 -0
- package/lib/routes-update.js +41 -0
- package/lib/routes-v07.js +380 -0
- package/lib/routes-v09.js +127 -0
- package/lib/routes-worldinfo.js +59 -0
- package/lib/routes.js +909 -0
- package/lib/segment.js +116 -0
- package/lib/services.js +121 -0
- package/lib/slang.js +355 -0
- package/lib/social.js +409 -0
- package/lib/soul-memory.js +567 -0
- package/lib/stickers.js +192 -0
- package/lib/storage.js +71 -0
- package/lib/theme.js +40 -0
- package/lib/time-util.js +19 -0
- package/lib/update-check.js +201 -0
- package/lib/video-frames.js +97 -0
- package/lib/voice.js +275 -0
- package/lib/websearch.js +109 -0
- package/lib/weekly.js +56 -0
- package/lib/worldinfo.js +240 -0
- package/package.json +77 -3
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-hive-queen(蜂巢 · 蜂后)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
蜂巢(AI 员工族群)的"蜂后"统一插件:一个蜂后,活在微信/QQ/B站/抖音四端,共享同一份记忆与人格,带分级权限、虚拟形象、多平台直播、圆桌会议、本地服务接入与飞牛运维驾驶舱。
|
|
4
|
+
|
|
5
|
+
The "Queen Bee" unified plugin of the Hive (AI employee swarm) for DeepSeek Harness: one queen living across WeChat / QQ / Bilibili / Douyin, sharing one memory and one persona, with tiered permissions, a virtual avatar, multi-platform live streaming, round-table (MoA), local service integration, and a fly-ops dashboard.
|
|
6
|
+
|
|
7
|
+
- 许可 / License:MIT
|
|
8
|
+
- 兼容 / Engines:DSH `^0.2.1-rc.5`,Node `>=22.5.0`
|
|
9
|
+
- 借鉴 / Inspired by:[dsh-fish-soul](https://github.com/Britneycode/dsh-fish-soul)(MIT)与 [dsh-self](https://github.com/xiaoyeziyinxing/dsh-self)(MIT)
|
|
10
|
+
|
|
11
|
+
## 能力清单 / Capabilities
|
|
12
|
+
|
|
13
|
+
| # | 能力 | 说明 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| 人格 | 人格卡(SillyTavern 兼容)+ 慢慢长 + 情绪引擎 | persona.json,跨天跨通道证据才生效 |
|
|
16
|
+
| 记忆 | 按人隔离(艾宾浩斯衰减 / 可纠正 / 聊完自动记)| 一个 soul.db 四端共享 |
|
|
17
|
+
| 通道 | 微信(iLink 扫码,无封号)/ QQ / B站 / 抖音 | 四端统一 |
|
|
18
|
+
| 权限 | 微信/QQ=control,B站/抖音=read + 目录级可勾选 | permissions.js |
|
|
19
|
+
| 服务接入 | hermes / redis / NoteDiscovery / Graphiti / Qdrant | services.js,只调用不重写 |
|
|
20
|
+
| 本地客户端 | 豆包 / 元宝 / kimi / GLM / workbuddy(GUI 优先)| desktop-client.js |
|
|
21
|
+
| 虚拟形象 | 外貌 / 性格 / 穿搭 / 生活 / 宠物(MiniMax 数字人)| avatar.js |
|
|
22
|
+
| 直播 | 抖音/B站虚拟形象直播 + 弹幕 + 打赏(OBS 推流)| live.js |
|
|
23
|
+
| UI 视觉 | 形象驱动主题(随穿搭更新,默认关)| theme.js |
|
|
24
|
+
| 运维 | 驾驶舱 + 服务导航 + 飞牛运维(DDNS/反代/WAF/子域名)| flyops.js |
|
|
25
|
+
|
|
26
|
+
## 安装 / Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
dsh plugin --profile desktop add dsh-hive-queen
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
(桌面版也可在「设置 → 插件」搜索安装)
|
|
33
|
+
|
|
34
|
+
## 配置要点 / Configuration
|
|
35
|
+
|
|
36
|
+
- **微信**:`wechat` → 扫码绑定(iLink Bot,无公众号/无封号)。
|
|
37
|
+
- **QQ**:`qq` → SnowLuma(OneBot 11 正向 WS,默认 `ws://127.0.0.1:3001`)。
|
|
38
|
+
- **服务**:`services` → 填 hermes/redis/NoteDiscovery/Graphiti/Qdrant 地址(留空=不启用)。
|
|
39
|
+
- **权限**:`permissions` → 端级 control/read + 目录级可读/可写。
|
|
40
|
+
- **形象/直播**:`avatar`(MiniMax 数字人)+ `live`(OBS 推流 + 弹幕/打赏)。
|
|
41
|
+
- **飞牛运维**:`flyops` → SSH 主机/端口/账号(高权限人工授权)。
|
|
42
|
+
|
|
43
|
+
## 诚实边界 / Honesty
|
|
44
|
+
|
|
45
|
+
虚拟形象、情绪、生活是"识别、状态模拟、策略偏置和表达生成",非真实主观感受,无体验连续性。AI 生成直播内容需遵守平台 AIGC 标注与直播规范。
|
|
46
|
+
|
|
47
|
+
## 开发 / Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install && npm run build # 构建 lib/
|
|
51
|
+
npm test # smoke 测试
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
fork 自 fish-soul 的结构:`lib/` 为源码(ESM),`cordis.patch.yml` 为 bundle patch。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attachments.js —— QQ 附件(文件消息)处理:文本类直接读进正文,看不了的格式存到插件数据目录再报路径。
|
|
3
|
+
*
|
|
4
|
+
* 只碰白名单目录里的文件(工作区 / 插件数据目录):路径不在里面、文件已经没了、读失败,
|
|
5
|
+
* 一律保留原来的【文件:名字】占位,不往提示词里塞来路不明的东西。
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
9
|
+
import { isInside } from './gateway-util.js';
|
|
10
|
+
|
|
11
|
+
/** 能直接读进提示词的扩展名。 */
|
|
12
|
+
const TEXT_EXT = new Set(['.txt', '.md', '.log', '.csv', '.json']);
|
|
13
|
+
/** 超过这个大小就不读(提示词放不下)。 */
|
|
14
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
15
|
+
/** 直读最多读前多少字节。 */
|
|
16
|
+
const MAX_READ_BYTES = 8 * 1024;
|
|
17
|
+
/** 二进制落盘单文件上限(超过就保留占位)。 */
|
|
18
|
+
const MAX_BINARY_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
/** 正文里附件的占位(onebot.js 生成的);用户自己打的【】会被换成〔〕,撞不上。 */
|
|
20
|
+
const PLACEHOLDER_RE = /【文件[^】]*】/g;
|
|
21
|
+
|
|
22
|
+
/** 附件开关:读文本 / 存二进制。 */
|
|
23
|
+
export function attachmentConfig(cfg) {
|
|
24
|
+
const a = cfg?.attachments ?? {};
|
|
25
|
+
return { readText: a.readText !== false, saveBinary: a.saveBinary === true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 文件名里不能有的字符(Windows 也不能有),顺手去掉开头的小点。 */
|
|
29
|
+
function safeName(name) {
|
|
30
|
+
return String(name ?? '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/^\.+/, '').trim().slice(0, 60);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 读前 8KB;切在多字节字符中间会多出一个替换字符,丢掉。 */
|
|
34
|
+
function readHead(abs) {
|
|
35
|
+
const buf = readFileSync(abs);
|
|
36
|
+
const truncated = buf.length > MAX_READ_BYTES;
|
|
37
|
+
const slice = buf.subarray(0, MAX_READ_BYTES);
|
|
38
|
+
// 先检测是否合法 UTF-8;非法就抛异常(调用方 catch 保留占位)
|
|
39
|
+
new TextDecoder('utf-8', { fatal: true }).decode(slice);
|
|
40
|
+
const text = slice.toString('utf8');
|
|
41
|
+
// 切在多字节字符中间会多出一个替换字符(U+FFFD),丢掉
|
|
42
|
+
const clean = text.length > 0 && text.codePointAt(text.length - 1) === 0xfffd ? text.slice(0, -1) : text;
|
|
43
|
+
return { text: clean, truncated };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* fileId 路线(SnowLuma 这类不给 path 的):get_private_file_url 换直链 → 下载 → 按扩展名读进正文或落盘。
|
|
48
|
+
* 拿不到链接 / 下载失败 / 超上限:返回 null 保留占位。
|
|
49
|
+
*/
|
|
50
|
+
async function handleDownload(f, ctx, meta) {
|
|
51
|
+
const { name, ext, size, label, warn } = meta;
|
|
52
|
+
const fileId = String(f?.fileId ?? '').trim();
|
|
53
|
+
const fileHash = String(f?.fileHash ?? '').trim();
|
|
54
|
+
if (typeof ctx.fetchFile !== 'function') { warn('没有文件下载通道'); return null; }
|
|
55
|
+
if (size > MAX_BINARY_BYTES) { warn('太大了,没拉'); return null; }
|
|
56
|
+
let bytes = null;
|
|
57
|
+
try {
|
|
58
|
+
bytes = await ctx.fetchFile({ fileId, fileHash, url: String(f?.url ?? '') });
|
|
59
|
+
} catch {
|
|
60
|
+
warn('下载失败');
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (bytes === null || bytes.length === 0) { warn('没拉到内容'); return null; }
|
|
64
|
+
if (bytes.length > MAX_BINARY_BYTES) { warn('太大了,没拉'); return null; }
|
|
65
|
+
|
|
66
|
+
if (TEXT_EXT.has(ext)) {
|
|
67
|
+
if (!ctx.readText) return null;
|
|
68
|
+
if (bytes.length > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
69
|
+
const slice = bytes.subarray(0, MAX_READ_BYTES);
|
|
70
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(slice); } catch { warn('不是有效 UTF-8'); return null; }
|
|
71
|
+
const text = slice.toString('utf8');
|
|
72
|
+
const clean = text.length > 0 && text.codePointAt(text.length - 1) === 0xfffd ? text.slice(0, -1) : text;
|
|
73
|
+
ctx.read.push(label);
|
|
74
|
+
const body = bytes.length > MAX_READ_BYTES ? `${clean}\n…(内容太长,只读了前 8KB)` : clean;
|
|
75
|
+
return `【文件 ${label} 内容】\n${body}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!ctx.saveBinary) return null;
|
|
79
|
+
const dir = String(ctx.dataDir ?? '');
|
|
80
|
+
if (dir === '') { warn('没有存放目录'); return null; }
|
|
81
|
+
const base = safeName(name);
|
|
82
|
+
const dest = join(dir, `${safeName(ctx.messageId) || 'file'}-${base !== '' ? base : '附件'}`);
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeFileSync(dest, bytes);
|
|
86
|
+
} catch {
|
|
87
|
+
warn('存不下来');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
ctx.saved.push(dest);
|
|
91
|
+
return `【文件 ${label}:我看不了这种格式,已存到 ${dest}】`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 一个附件怎么处理:返回替换占位的新文本,处理不了返回 null(占位原样留着)。
|
|
96
|
+
* SnowLuma 这类不给本地 path 的:先用 fetchFile(get_private_file_url 换链接再下载)拿到 bytes 再走同一套逻辑。
|
|
97
|
+
* @param {{name?: string, path?: string, url?: string, size?: number, fileId?: string, fileHash?: string}} f
|
|
98
|
+
* @param {object} ctx
|
|
99
|
+
*/
|
|
100
|
+
async function handleOne(f, ctx) {
|
|
101
|
+
const name = String(f?.name ?? '').trim();
|
|
102
|
+
const rawPath = String(f?.path ?? '').trim();
|
|
103
|
+
const abs = rawPath === '' ? '' : resolve(rawPath);
|
|
104
|
+
const ext = extname(name !== '' ? name : rawPath).toLowerCase();
|
|
105
|
+
const size = Number(f?.size) || 0;
|
|
106
|
+
const label = name !== '' ? name : rawPath !== '' ? basename(rawPath) : '未命名';
|
|
107
|
+
const warn = (why) => ctx.log?.('warn', `[hive-queen] 附件没读进去(${why}):${label}`);
|
|
108
|
+
|
|
109
|
+
// SnowLuma 路线:没有本地 path,但有 fileId——先用 OneBot API 换链接下载
|
|
110
|
+
const fileId = String(f?.fileId ?? '').trim();
|
|
111
|
+
if (abs === '' && fileId !== '') return await handleDownload(f, ctx, { name, ext, size, label, warn });
|
|
112
|
+
|
|
113
|
+
if (abs === '') { warn('没有本地路径'); return null; }
|
|
114
|
+
if (!ctx.roots.some((root) => isInside(root, abs))) { warn('路径不在允许的目录里'); return null; }
|
|
115
|
+
if (!existsSync(abs)) { warn('文件不在了'); return null; }
|
|
116
|
+
|
|
117
|
+
// P0 修复:白名单和源文件都做 realpath,防符号链接 / Windows junction 绕过
|
|
118
|
+
let realRoots;
|
|
119
|
+
try { realRoots = ctx.roots.filter((r) => { try { realpathSync.native(r); return true; } catch { return false; } }).map((r) => realpathSync.native(r)); } catch { realRoots = []; }
|
|
120
|
+
let realAbs;
|
|
121
|
+
try { realAbs = realpathSync.native(abs); } catch { warn('解析真实路径失败'); return null; }
|
|
122
|
+
if (!realRoots.some((root) => isInside(root, realAbs))) { warn('真实路径越界(符号链接/junction)'); return null; }
|
|
123
|
+
|
|
124
|
+
if (TEXT_EXT.has(ext)) {
|
|
125
|
+
if (!ctx.readText) return null; // 开关关着:跟以前一样,只留占位
|
|
126
|
+
if (size > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
127
|
+
let st;
|
|
128
|
+
try { st = statSync(realAbs); } catch { warn('拿不到文件信息'); return null; }
|
|
129
|
+
if (!st.isFile()) { warn('不是文件'); return null; }
|
|
130
|
+
if (st.size > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
131
|
+
let head;
|
|
132
|
+
try { head = readHead(realAbs); } catch { warn('读取失败'); return null; }
|
|
133
|
+
ctx.read.push(label);
|
|
134
|
+
const body = head.truncated ? `${head.text}\n…(内容太长,只读了前 8KB)` : head.text;
|
|
135
|
+
return `【文件 ${label} 内容】\n${body}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!ctx.saveBinary) return null; // 不存二进制:跟以前一样,只留占位
|
|
139
|
+
// P1 修复:二进制也设上限,防大文件占满磁盘
|
|
140
|
+
if (size > MAX_BINARY_BYTES) { warn('二进制太大,没存'); return null; }
|
|
141
|
+
let bst;
|
|
142
|
+
try { bst = statSync(realAbs); } catch { warn('拿不到文件信息'); return null; }
|
|
143
|
+
if (!bst.isFile()) { warn('不是普通文件'); return null; }
|
|
144
|
+
if (bst.size > MAX_BINARY_BYTES) { warn('二进制太大,没存'); return null; }
|
|
145
|
+
const dir = String(ctx.dataDir ?? '');
|
|
146
|
+
if (dir === '') { warn('没有存放目录'); return null; }
|
|
147
|
+
const base = safeName(name) !== '' ? safeName(name) : safeName(basename(realAbs));
|
|
148
|
+
const dest = join(dir, `${safeName(ctx.messageId) || 'file'}-${base !== '' ? base : '附件'}`);
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
copyFileSync(realAbs, dest);
|
|
152
|
+
} catch {
|
|
153
|
+
warn('存不下来');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
ctx.saved.push(dest);
|
|
157
|
+
return `【文件 ${label}:我看不了这种格式,已存到 ${dest}】`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 把附件处理进消息正文:占位换成内容(或存下来的路径),处理不了的占位原样留着。
|
|
162
|
+
* @param {string} text 原正文(里面有【文件:名字】占位)
|
|
163
|
+
* @param {Array<{name?: string, path?: string, url?: string, size?: number}>} files 附件(顺序与占位一致)
|
|
164
|
+
* @param {{readText?: boolean, saveBinary?: boolean, roots?: string[], dataDir?: string, messageId?: string, log?: (level: string, msg: string) => void}} opts
|
|
165
|
+
* @returns {{text: string, read: string[], saved: string[]}} read = 读进正文的附件名,saved = 落盘的绝对路径
|
|
166
|
+
*/
|
|
167
|
+
export async function applyAttachments(text, files, opts = {}) {
|
|
168
|
+
const src = String(text ?? '');
|
|
169
|
+
const list = Array.isArray(files) ? files : [];
|
|
170
|
+
if (list.length === 0) return { text: src, read: [], saved: [] };
|
|
171
|
+
const marks = [...src.matchAll(PLACEHOLDER_RE)];
|
|
172
|
+
if (marks.length === 0) return { text: src, read: [], saved: [] };
|
|
173
|
+
const ctx = { readText: true, saveBinary: false, roots: [], dataDir: '', messageId: '', log: () => {}, ...opts };
|
|
174
|
+
const read = [];
|
|
175
|
+
const saved = [];
|
|
176
|
+
ctx.read = read;
|
|
177
|
+
ctx.saved = saved;
|
|
178
|
+
const repl = await Promise.all(marks.map((mark, i) => (list[i] === undefined ? null : handleOne(list[i], ctx))));
|
|
179
|
+
// 从后往前替换,前面占位的位置不受影响
|
|
180
|
+
let out = src;
|
|
181
|
+
for (let i = repl.length - 1; i >= 0; i--) {
|
|
182
|
+
if (repl[i] === null) continue;
|
|
183
|
+
const at = marks[i].index;
|
|
184
|
+
out = out.slice(0, at) + repl[i] + out.slice(at + marks[i][0].length);
|
|
185
|
+
}
|
|
186
|
+
return { text: out, read, saved };
|
|
187
|
+
}
|
package/lib/avatar.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* avatar.js —— N6 唯一虚拟形象:形象数据 + MiniMax 数字人生成 + 今日穿搭。
|
|
3
|
+
*
|
|
4
|
+
* - 形象数据存 config(avatarId / appearance / themeColor / accentColor / outfits)。
|
|
5
|
+
* - MiniMax 数字人 API(3.7.1 密钥):生成形象 / 口型,端点以 MiniMax 官方文档为准。
|
|
6
|
+
* - 诚实边界:数字人形象是生成内容,非真实人格;性格由 L1 人格卡承载,形象只是外在。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 按今日日期在穿搭列表里确定性挑一件(同一天稳定,同 theme.outfitOfDay 逻辑)。 */
|
|
10
|
+
export function outfitOfDay(outfits, dateStr, fallbackColor = '#6b7fd7') {
|
|
11
|
+
const list = Array.isArray(outfits) && outfits.length > 0 ? outfits : [{ color: fallbackColor, name: '日常' }];
|
|
12
|
+
const seed = [...String(dateStr ?? '')].reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
13
|
+
return list[seed % list.length];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createAvatar({ getConfig, log, fetchImpl }) {
|
|
17
|
+
const cfg = () => getConfig()?.avatar ?? {};
|
|
18
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
/** 当前形象(合并默认)。 */
|
|
22
|
+
current() {
|
|
23
|
+
const c = cfg();
|
|
24
|
+
return {
|
|
25
|
+
avatarId: String(c.avatarId ?? ''),
|
|
26
|
+
appearance: String(c.appearance ?? ''),
|
|
27
|
+
themeColor: String(c.themeColor ?? '#6b7fd7'),
|
|
28
|
+
accentColor: String(c.accentColor ?? '#9c6bde'),
|
|
29
|
+
outfits: Array.isArray(c.outfits) ? c.outfits : [],
|
|
30
|
+
minimaxApiKey: String(c.minimaxApiKey ?? ''),
|
|
31
|
+
minimaxGroupId: String(c.minimaxGroupId ?? ''),
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/** 今日穿搭。 */
|
|
36
|
+
todayOutfit(dateStr) {
|
|
37
|
+
const c = this.current();
|
|
38
|
+
return outfitOfDay(c.outfits, dateStr, c.themeColor);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** MiniMax 文本生成:为虚拟形象生成外貌描述与主题色(端点:chatcompletion_v2)。 */
|
|
42
|
+
async generate(prompt) {
|
|
43
|
+
const c = this.current();
|
|
44
|
+
if (!c.minimaxApiKey) return null;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetchFn('https://api.minimax.chat/v1/text/chatcompletion_v2', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${c.minimaxApiKey}` },
|
|
49
|
+
body: JSON.stringify({ model: 'MiniMax-M1', messages: [{ role: 'user', content: `为虚拟形象生成外貌描述与主题色:${prompt}` }] }),
|
|
50
|
+
signal: AbortSignal.timeout(30000),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) return null;
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
return data?.choices?.[0]?.message?.content ?? null;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log?.('warn', `[hive-queen] MiniMax 形象生成失败:${String(err instanceof Error ? err.message : err)}`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/** MiniMax 数字人视频生成(Talking Avatar / Lip-Sync,模型 MiniMax-H3)。返回任务 id,轮询取结果。 */
|
|
62
|
+
async videoGenerate({ prompt = '', imageUrl = '', audioUrl = '' }) {
|
|
63
|
+
const c = this.current();
|
|
64
|
+
if (!c.minimaxApiKey) return null;
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetchFn('https://api.minimax.chat/v1/video_generation', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${c.minimaxApiKey}` },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
model: 'MiniMax-H3',
|
|
71
|
+
prompt,
|
|
72
|
+
...(imageUrl ? { first_frame_image: imageUrl } : {}),
|
|
73
|
+
...(audioUrl ? { audio: audioUrl } : {}),
|
|
74
|
+
}),
|
|
75
|
+
signal: AbortSignal.timeout(30000),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) return null;
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
return data?.task_id ?? data?.id ?? null;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log?.('warn', `[hive-queen] MiniMax 数字人视频生成失败:${String(err instanceof Error ? err.message : err)}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/lib/backup.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backup.js —— soul.db 备份(v0.6):养了半年的"她"不能因为一次误删 / 硬盘坏掉就没了。
|
|
3
|
+
*
|
|
4
|
+
* - `VACUUM INTO` 拷一份完整、一致的库(WAL 模式下也安全,不用停插件)到 $DSH_HOME/hive-queen/backups/;
|
|
5
|
+
* - 打开自动备份时每天一份(真实时间,插件启动 1 分钟后先看一眼),只留最近 keep 份;
|
|
6
|
+
* - 恢复(设置页点「恢复到这份」):先校验备份能打开、结构对、quick_check 通过,记一个 restore-pending.json;
|
|
7
|
+
* 下次插件启动、打开 soul.db 之前换上去(运行中的库被很多模块拿着,不在跑着的时候硬换)。换之前把当前的库
|
|
8
|
+
* VACUUM INTO 另存成 backups/pre-restore-时间.db(恢复错了还能换回来,最多留 3 份),再删掉旧的 -wal / -shm。
|
|
9
|
+
*/
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { hiveQueenDir, getKv, setKv } from './db.js';
|
|
14
|
+
import { stampLocal as stamp } from './time-util.js';
|
|
15
|
+
|
|
16
|
+
const DAY_MS = 24 * 3600_000;
|
|
17
|
+
const CHECK_MS = 3600_000;
|
|
18
|
+
const NAME_RE = /^soul-\d{8}-\d{6}\.db$/;
|
|
19
|
+
const PRE_RE = /^pre-restore-\d{8}-\d{6}\.db$/;
|
|
20
|
+
const PRE_KEEP = 3;
|
|
21
|
+
const REQUIRED_TABLES = ['memory', 'person', 'kv'];
|
|
22
|
+
|
|
23
|
+
function pendingPath() {
|
|
24
|
+
return join(hiveQueenDir(), 'restore-pending.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 等着下次启动换上去的备份(没有 = null)。 */
|
|
28
|
+
export function pendingRestore() {
|
|
29
|
+
try { return JSON.parse(readFileSync(pendingPath(), 'utf8')); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 带着 -wal 的库文件(恢复前另存时库打不开、只能原样拷文件 + WAL):把 WAL 合并回主文件、改回单文件。
|
|
34
|
+
* 合并不了(库坏了)就原样留着,恢复时连 WAL 一起拷。返回合并后还有没有 -wal。
|
|
35
|
+
*/
|
|
36
|
+
function consolidateWal(path) {
|
|
37
|
+
if (!existsSync(`${path}-wal`)) return false;
|
|
38
|
+
try {
|
|
39
|
+
const db = new DatabaseSync(path);
|
|
40
|
+
try {
|
|
41
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
42
|
+
db.exec('PRAGMA journal_mode = DELETE');
|
|
43
|
+
} finally { db.close(); }
|
|
44
|
+
} catch { /* 合并不了:留着 WAL */ }
|
|
45
|
+
if (existsSync(`${path}-wal`) && statSync(`${path}-wal`).size === 0) rmSync(`${path}-wal`, { force: true });
|
|
46
|
+
rmSync(`${path}-shm`, { force: true });
|
|
47
|
+
return existsSync(`${path}-wal`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 备份文件能不能用:打得开、quick_check 通过、有记忆 / 人物 / kv 表。返回 { memories, persons }。 */
|
|
51
|
+
function inspectBackup(path) {
|
|
52
|
+
consolidateWal(path);
|
|
53
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
54
|
+
try {
|
|
55
|
+
const check = db.prepare('PRAGMA quick_check').get();
|
|
56
|
+
const ok = Object.values(check ?? {})[0];
|
|
57
|
+
if (ok !== 'ok') throw new Error(`备份文件损坏(${String(ok).slice(0, 60)})`);
|
|
58
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
59
|
+
const missing = REQUIRED_TABLES.filter((t) => !tables.has(t));
|
|
60
|
+
if (missing.length > 0) throw new Error(`不像 soul.db 的备份(缺表 ${missing.join('、')})`);
|
|
61
|
+
return {
|
|
62
|
+
memories: db.prepare("SELECT COUNT(*) AS n FROM memory WHERE status = 'active'").get().n,
|
|
63
|
+
persons: db.prepare('SELECT COUNT(*) AS n FROM person').get().n
|
|
64
|
+
};
|
|
65
|
+
} finally {
|
|
66
|
+
db.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 插件启动时、openDatabase() 之前调:有待恢复的备份就换上去。
|
|
72
|
+
* @returns {{file: string, safety: string|null, at: number} | null}
|
|
73
|
+
*/
|
|
74
|
+
export function applyPendingRestore({ log = () => {}, nowMs = Date.now() } = {}) {
|
|
75
|
+
const pending = pendingRestore();
|
|
76
|
+
if (pending === null) return null;
|
|
77
|
+
const dir = backupDir();
|
|
78
|
+
const src = join(dir, String(pending.file ?? ''));
|
|
79
|
+
const target = join(hiveQueenDir(), 'soul.db');
|
|
80
|
+
const drop = () => rmSync(pendingPath(), { force: true });
|
|
81
|
+
if (!(NAME_RE.test(pending.file) || PRE_RE.test(pending.file)) || !existsSync(src)) {
|
|
82
|
+
log('warn', `[hive-queen] 要恢复的备份 ${pending.file} 不在了,没换`);
|
|
83
|
+
drop();
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
inspectBackup(src);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
log('error', `[hive-queen] 要恢复的备份 ${pending.file} 校验失败,没换:${String(error instanceof Error ? error.message : error)}`);
|
|
90
|
+
drop();
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
// 当前的库先另存一份(VACUUM INTO 连 WAL 里没合并的也带上;库坏到打不开就原样拷文件)
|
|
94
|
+
let safety = null;
|
|
95
|
+
if (existsSync(target)) {
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
let name = `pre-restore-${stamp(nowMs)}.db`;
|
|
98
|
+
for (let i = 1; existsSync(join(dir, name)); i++) name = `pre-restore-${stamp(nowMs + i * 1000)}.db`;
|
|
99
|
+
const out = join(dir, name);
|
|
100
|
+
try {
|
|
101
|
+
const cur = new DatabaseSync(target);
|
|
102
|
+
try { cur.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { cur.close(); }
|
|
103
|
+
} catch {
|
|
104
|
+
copyFileSync(target, out);
|
|
105
|
+
if (existsSync(`${target}-wal`)) copyFileSync(`${target}-wal`, `${out}-wal`);
|
|
106
|
+
// 最后一次 checkpoint 之后的写入都在 WAL 里:合并进另存的那份(换回去时才不会丢)
|
|
107
|
+
consolidateWal(out);
|
|
108
|
+
}
|
|
109
|
+
safety = name;
|
|
110
|
+
}
|
|
111
|
+
const tmp = `${target}.restoring`;
|
|
112
|
+
// 要恢复的那份带着 -wal(合并不了的另存):WAL 跟着一起换上,否则最后一次 checkpoint 之后的数据全丢
|
|
113
|
+
const srcWal = consolidateWal(src) ? `${src}-wal` : null;
|
|
114
|
+
copyFileSync(src, tmp);
|
|
115
|
+
if (srcWal) copyFileSync(srcWal, `${tmp}-wal`);
|
|
116
|
+
rmSync(`${target}-wal`, { force: true });
|
|
117
|
+
rmSync(`${target}-shm`, { force: true });
|
|
118
|
+
renameSync(tmp, target);
|
|
119
|
+
if (srcWal) renameSync(`${tmp}-wal`, `${target}-wal`);
|
|
120
|
+
drop();
|
|
121
|
+
// 恢复前的另存只留最近几份
|
|
122
|
+
try {
|
|
123
|
+
const pres = readdirSync(dir).filter((f) => PRE_RE.test(f)).sort().reverse();
|
|
124
|
+
for (const f of pres.slice(PRE_KEEP)) for (const x of [f, `${f}-wal`, `${f}-shm`]) rmSync(join(dir, x), { force: true });
|
|
125
|
+
} catch { /* 下次再删 */ }
|
|
126
|
+
log('info', `[hive-queen] 已从备份 ${pending.file} 恢复 soul.db${safety ? `(恢复前的库另存为 ${safety})` : ''}`);
|
|
127
|
+
return { file: pending.file, safety, at: nowMs };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function backupDir() {
|
|
131
|
+
return join(hiveQueenDir(), 'backups');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class BackupService {
|
|
135
|
+
/**
|
|
136
|
+
* @param {object} deps
|
|
137
|
+
* @param {import('node:sqlite').DatabaseSync} deps.db
|
|
138
|
+
* @param {() => {auto?: boolean, keep?: number}} deps.getConfig
|
|
139
|
+
* @param {(level: string, msg: string) => void} [deps.log]
|
|
140
|
+
*/
|
|
141
|
+
constructor({ db, getConfig, log }) {
|
|
142
|
+
this.db = db;
|
|
143
|
+
this.getConfig = getConfig;
|
|
144
|
+
this.log = log ?? (() => {});
|
|
145
|
+
this.timer = null;
|
|
146
|
+
this.first = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
config() {
|
|
150
|
+
const c = this.getConfig() ?? {};
|
|
151
|
+
const keep = Math.floor(Number(c.keep));
|
|
152
|
+
return { auto: c.auto !== false, keep: Number.isFinite(keep) ? Math.min(60, Math.max(1, keep)) : 7 };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
start() {
|
|
156
|
+
const check = () => { try { this.maybeAuto(); } catch (error) { this.log('warn', `[hive-queen] 自动备份失败:${String(error instanceof Error ? error.message : error)}`); } };
|
|
157
|
+
this.first = setTimeout(check, 60_000);
|
|
158
|
+
this.first.unref?.();
|
|
159
|
+
this.timer = setInterval(check, CHECK_MS);
|
|
160
|
+
this.timer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
stop() {
|
|
164
|
+
clearTimeout(this.first);
|
|
165
|
+
clearInterval(this.timer);
|
|
166
|
+
this.first = null;
|
|
167
|
+
this.timer = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 自动备份打开、且距上次备份满一天:备一份。 */
|
|
171
|
+
maybeAuto(nowMs = Date.now()) {
|
|
172
|
+
const cfg = this.config();
|
|
173
|
+
if (!cfg.auto) return null;
|
|
174
|
+
const last = Number(getKv(this.db, 'backup.lastAt', 0)) || 0;
|
|
175
|
+
if (nowMs - last < DAY_MS) return null;
|
|
176
|
+
return this.backupNow(nowMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 立刻备一份;返回 { file, path, size, at }。 */
|
|
180
|
+
backupNow(nowMs = Date.now()) {
|
|
181
|
+
const dir = backupDir();
|
|
182
|
+
mkdirSync(dir, { recursive: true });
|
|
183
|
+
let name = `soul-${stamp(nowMs)}.db`;
|
|
184
|
+
for (let i = 1; existsSync(join(dir, name)); i++) name = `soul-${stamp(nowMs + i * 1000)}.db`;
|
|
185
|
+
const path = join(dir, name);
|
|
186
|
+
// VACUUM INTO 的目标路径不能用参数绑定,只能拼字符串:路径是我们自己生成的,单引号转义一下兜底
|
|
187
|
+
this.db.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
|
|
188
|
+
setKv(this.db, 'backup.lastAt', nowMs);
|
|
189
|
+
this.prune();
|
|
190
|
+
const size = statSync(path).size;
|
|
191
|
+
this.log('info', `[hive-queen] 已备份 soul.db → ${path}(${Math.round(size / 1024)} KB)`);
|
|
192
|
+
return { file: name, path, size, at: nowMs };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 只留最近 keep 份(只动我们自己生成的 soul-YYYYMMDD-HHMMSS.db)。 */
|
|
196
|
+
prune() {
|
|
197
|
+
const { keep } = this.config();
|
|
198
|
+
const files = this.list();
|
|
199
|
+
for (const f of files.slice(keep)) {
|
|
200
|
+
try { unlinkSync(join(backupDir(), f.file)); } catch { /* 下次再删 */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 现有备份,新的在前(只是自动 / 手动备份;恢复前的另存见 preRestoreList)。 */
|
|
205
|
+
list() {
|
|
206
|
+
return this.files(NAME_RE);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
preRestoreList() {
|
|
210
|
+
return this.files(PRE_RE).map((f) => ({ ...f, preRestore: true }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
files(re) {
|
|
214
|
+
const dir = backupDir();
|
|
215
|
+
if (!existsSync(dir)) return [];
|
|
216
|
+
return readdirSync(dir).filter((f) => re.test(f)).map((f) => {
|
|
217
|
+
const st = statSync(join(dir, f));
|
|
218
|
+
return { file: f, size: st.size, at: st.mtimeMs };
|
|
219
|
+
}).sort((a, b) => (a.file < b.file ? 1 : -1));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 选一份备份,下次启动时换上去(先校验)。 */
|
|
223
|
+
stageRestore(file) {
|
|
224
|
+
const name = String(file ?? '');
|
|
225
|
+
if (!(NAME_RE.test(name) || PRE_RE.test(name))) throw new Error('不是插件生成的备份文件');
|
|
226
|
+
const path = join(backupDir(), name);
|
|
227
|
+
if (!existsSync(path)) throw new Error('这份备份不在了');
|
|
228
|
+
const info = inspectBackup(path);
|
|
229
|
+
const pending = { file: name, at: Date.now(), ...info };
|
|
230
|
+
writeFileSync(pendingPath(), JSON.stringify(pending), 'utf8');
|
|
231
|
+
this.log('info', `[hive-queen] 已选好要恢复的备份 ${name}(${info.memories} 条记忆),重启 DSH 后换上`);
|
|
232
|
+
return pending;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
cancelRestore() {
|
|
236
|
+
const had = pendingRestore() !== null;
|
|
237
|
+
rmSync(pendingPath(), { force: true });
|
|
238
|
+
return had;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
state() {
|
|
242
|
+
const cfg = this.config();
|
|
243
|
+
return {
|
|
244
|
+
...cfg, dir: backupDir(), lastAt: Number(getKv(this.db, 'backup.lastAt', 0)) || null, list: this.list().slice(0, 10),
|
|
245
|
+
preRestore: this.preRestoreList(), pendingRestore: pendingRestore(), lastRestored: getKv(this.db, 'backup.restored', null)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|