dsh-fish-soul 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/client.js +6 -2
- package/lib/client.soul.js +71 -10
- package/lib/panel.css +1 -0
- package/lib/routes-v07.js +61 -10
- package/lib/stickers.js +73 -7
- package/package.json +3 -3
package/README.md
CHANGED
package/lib/client.js
CHANGED
|
@@ -360,7 +360,8 @@ window.__ModuleLoader__.load({
|
|
|
360
360
|
const [form, setForm, patch] = useForm(data, () => ({
|
|
361
361
|
enabled: qq.enabled === true, wsUrl: qq.wsUrl ?? "ws://127.0.0.1:3001", accessToken: "", masterQqUin: data.masterQqUin ?? "",
|
|
362
362
|
groupAllowList: (qq.groupAllowList ?? []).join(", "), allGroups: qq.allGroups === true, followUpCount: qq.followUpCount ?? 3, followUpSeconds: qq.followUpSeconds ?? 120, batchMs: qq.batchMs ?? 3000,
|
|
363
|
-
callNames: (qq.callNames ?? []).join(", "), ij: { ...(qq.interject ?? {}), chance: Math.round((qq.interject?.chance ?? 0.05) * 100), keywords: (qq.interject?.keywords ?? []).join(", ") }
|
|
363
|
+
callNames: (qq.callNames ?? []).join(", "), ij: { ...(qq.interject ?? {}), chance: Math.round((qq.interject?.chance ?? 0.05) * 100), keywords: (qq.interject?.keywords ?? []).join(", ") },
|
|
364
|
+
stickersPerDay: qq.stickers?.perDay ?? 10
|
|
364
365
|
}));
|
|
365
366
|
if (!data || !form) return null;
|
|
366
367
|
|
|
@@ -410,7 +411,10 @@ window.__ModuleLoader__.load({
|
|
|
410
411
|
form.enabled && ij.enabled ? Row({ title: "插话概率" }, ijNum("chance", "插话概率", 0, 100), Muted("%"), ijNum("maxPerHour", "每小时最多", 0, 30), Muted("次/小时")) : null,
|
|
411
412
|
form.enabled && ij.enabled ? Row({ title: "说完话后冷却" }, ijNum("cooldownMinutes", "冷却", 0, 1440), Muted("分钟")) : null,
|
|
412
413
|
form.enabled && ij.enabled ? Row({ title: "感兴趣的话题" }, Input({ value: ij.keywords ?? "", placeholder: "猫, 原神, 考研", onChange: (e) => patch({ ij: { ...ij, keywords: e.target.value } }), onBlur: () => save() })) : null,
|
|
413
|
-
form.enabled ? Row({ title: "收藏表情包", desc: qq.stickers?.collect !== false ? undefined : "看到喜欢的表情会存下来,之后自己用" },
|
|
414
|
+
form.enabled ? Row({ title: "收藏表情包", desc: qq.stickers?.collect !== false ? undefined : "看到喜欢的表情会存下来,之后自己用" },
|
|
415
|
+
h(Switch, { label: "收藏表情包", checked: qq.stickers?.collect !== false, onChange: (v) => run("/stickers/config", { collect: v }, v ? "会收藏喜欢的表情了" : "不再收藏表情", "保存失败") }),
|
|
416
|
+
Num({ min: 0, max: 100, value: form.stickersPerDay, "aria-label": "每天收藏几张", onChange: (e) => patch({ stickersPerDay: e.target.value }), onBlur: () => { if (form.stickersPerDay !== "") run("/stickers/config", { perDay: form.stickersPerDay }, "每天的上限改了", "保存失败"); } }),
|
|
417
|
+
Muted("张/天")) : null,
|
|
414
418
|
form.enabled ? Row({ title: "学群里的说法", desc: qq.slang?.enabled !== false ? undefined : "学每个群的梗和说话风格" }, h(Switch, { label: "学群里的说法", checked: qq.slang?.enabled !== false, onChange: (v) => run("/slang/config", { enabled: v }, v ? "会学群里的说法了" : "不再学群里的说法", "保存失败") })) : null,
|
|
415
419
|
form.enabled && qq.slang?.enabled !== false ? Row({ title: "学说话方式", desc: "从别人的话里学「什么场合怎么说」,只学别人的、不带人名" }, h(Switch, { label: "学说话方式", checked: qq.slang?.expressions !== false, onChange: (v) => run("/slang/config", { expressions: v }, v ? "会学说话方式了" : "不再学说话方式", "保存失败") })) : null,
|
|
416
420
|
form.enabled && qq.slang?.enabled !== false ? Row({ title: "私聊也学", desc: "好友私聊里学到的只在那个私聊里用" }, h(Switch, { label: "私聊也学", checked: qq.slang?.privateChats !== false, onChange: (v) => run("/slang/config", { privateChats: v }, v ? "私聊里也会学了" : "只学群里的", "保存失败") })) : null),
|
package/lib/client.soul.js
CHANGED
|
@@ -424,23 +424,84 @@ window.__ModuleLoader__.load({
|
|
|
424
424
|
recalled: ["撤回", "outline"], command: ["命令", "info"], proactive: ["主动", "info"], digest: ["记下", "success"],
|
|
425
425
|
filtered: ["改写", "outline"], diary: ["日记", "success"], browse: ["刷视频", "outline"], share: ["分享", "success"], post: ["发动态", "success"], comment: ["评论区", "info"], mood: ["心情", "info"], poke: ["戳回", "info"], react: ["回应", "info"]
|
|
426
426
|
};
|
|
427
|
-
/**
|
|
427
|
+
/** 读本地图片 → base64(去掉 data: 前缀);读不出来给 null。 */
|
|
428
|
+
function readBase64(file) {
|
|
429
|
+
return new Promise((resolve) => {
|
|
430
|
+
const reader = new FileReader();
|
|
431
|
+
reader.onload = () => { const s = String(reader.result ?? ""); resolve(s.includes(",") ? s.slice(s.indexOf(",") + 1) : null); };
|
|
432
|
+
reader.onerror = () => resolve(null);
|
|
433
|
+
reader.readAsDataURL(file);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 一张表情:名字能直接改(失焦 / 回车保存,Esc 撤销),也能删 */
|
|
438
|
+
function StickerCard({ x, api, apiBase, notify, refresh, relTime, ui, onRemove }) {
|
|
439
|
+
const { h, Input, IconButton } = ui;
|
|
440
|
+
const base = x.file.replace(/\.[a-z]+$/i, "");
|
|
441
|
+
const [name, setName] = react.useState(base);
|
|
442
|
+
const [busy, setBusy] = react.useState(false);
|
|
443
|
+
const saving = react.useRef(false);
|
|
444
|
+
const done = react.useRef(false);
|
|
445
|
+
async function save() {
|
|
446
|
+
if (saving.current || done.current) return;
|
|
447
|
+
const next = name.trim();
|
|
448
|
+
if (next === "" || next === base) { setName(base); return; }
|
|
449
|
+
saving.current = true; setBusy(true);
|
|
450
|
+
const r = await api("POST", "/stickers/rename", { file: x.file, workspacePath: x.workspacePath, name: next });
|
|
451
|
+
saving.current = false; setBusy(false);
|
|
452
|
+
if (r?.ok) { done.current = true; notify("改好了"); refresh(); }
|
|
453
|
+
else { notify(r?.error ?? "改名失败"); setName(base); }
|
|
454
|
+
}
|
|
455
|
+
const tip = [x.file, x.tracked ? (x.from === "主人" ? "主人导入的" : x.from ? `从 ${x.from} 那里` : "") : "主人自己放进目录的", x.summary, relTime(x.at, Date.now())].filter(Boolean).join("\n");
|
|
456
|
+
return h("figure", { className: "fs-sticker", title: tip },
|
|
457
|
+
h("img", { alt: base, loading: "lazy", src: `${apiBase}/stickers/file?file=${encodeURIComponent(x.file)}&ws=${encodeURIComponent(x.workspacePath)}` }),
|
|
458
|
+
h("figcaption", null,
|
|
459
|
+
Input({ value: name, "aria-label": `给 ${x.file} 改名`, disabled: busy, onChange: (e) => setName(e.target.value), onBlur: save, onKeyDown: (e) => { if (e.key === "Enter") save(); else if (e.key === "Escape") setName(base); } })),
|
|
460
|
+
IconButton("close", "删除", () => onRemove(x)));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 表情包:她收藏的 + 主人自己放进 _stickers 的(动态页底部) */
|
|
428
464
|
function StickerShelf({ api, apiBase, notify, relTime, ui }) {
|
|
429
|
-
const { h, Group, Tag, Empty,
|
|
465
|
+
const { h, Group, Tag, Empty, Row, Button, Muted } = ui;
|
|
430
466
|
const [data, load] = usePoll(api, "/stickers/state", 15000);
|
|
467
|
+
const [busy, setBusy] = react.useState(false);
|
|
468
|
+
const picker = react.useRef(null);
|
|
431
469
|
if (!data || data.ok === false) return null;
|
|
432
|
-
|
|
433
|
-
|
|
470
|
+
// 最近的排前面:主人刚放进目录的图不会被收藏记录挤出前 60 张
|
|
471
|
+
const items = (data.items ?? []).filter((x) => x.exists).slice().sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
|
|
434
472
|
async function remove(x) {
|
|
435
473
|
const r = await api("POST", "/stickers/delete", { file: x.file, workspacePath: x.workspacePath });
|
|
436
474
|
if (r?.ok) { notify("删掉了"); load(); } else notify(r?.error ?? "删除失败");
|
|
437
475
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
476
|
+
async function importFiles(files) {
|
|
477
|
+
const list = Array.from(files ?? []);
|
|
478
|
+
if (list.length === 0) return;
|
|
479
|
+
setBusy(true);
|
|
480
|
+
let doneCount = 0; let skipped = 0; let failed = 0; let full = false;
|
|
481
|
+
for (const f of list) {
|
|
482
|
+
if (f.size > 4 * 1024 * 1024) { skipped++; continue; }
|
|
483
|
+
const b64 = await readBase64(f);
|
|
484
|
+
if (b64 === null) { failed++; continue; }
|
|
485
|
+
const r = await api("POST", "/stickers/import", { name: f.name.replace(/\.[a-z]+$/i, ""), mediaType: f.type, base64: b64 });
|
|
486
|
+
if (r?.ok) doneCount++;
|
|
487
|
+
else if (r?.full === true) { failed++; full = true; break; }
|
|
488
|
+
else failed++;
|
|
489
|
+
}
|
|
490
|
+
setBusy(false);
|
|
491
|
+
const parts = [];
|
|
492
|
+
if (doneCount > 0) parts.push(`导入了 ${doneCount} 张`);
|
|
493
|
+
if (skipped > 0) parts.push(`${skipped} 张超过 4MB 跳过`);
|
|
494
|
+
if (failed > 0) parts.push(`${failed} 张没导进去`);
|
|
495
|
+
if (full) parts.push("_stickers 满了");
|
|
496
|
+
notify(parts.length > 0 ? parts.join(",") : "没有导入");
|
|
497
|
+
load();
|
|
498
|
+
}
|
|
499
|
+
return Group({ title: "表情包", aside: items.length ? Tag(String(items.length), "outline") : null },
|
|
500
|
+
Row({ title: "导入本地图片" },
|
|
501
|
+
h("input", { type: "file", accept: "png,jpg,jpeg,gif,webp", multiple: true, ref: picker, style: { display: "none" }, "aria-label": "选要导入的表情图", onChange: (e) => { const files = e.target.files; e.target.value = ""; importFiles(files); } }),
|
|
502
|
+
Button({ variant: "ghost", disabled: busy, "aria-label": "导入本地图片", onClick: () => picker.current?.click() }, busy ? "导入中…" : "选图导入"),
|
|
503
|
+
Muted("不占每天收藏的额度")),
|
|
504
|
+
items.length === 0 ? Empty("还没有表情包。聊天里看到喜欢的,她会自己存下来;你也可以直接把图片放进工作区的 _stickers 目录") : h("div", { className: "fs-stickers" }, items.slice(0, 60).map((x) => h(StickerCard, { key: `${x.workspacePath}/${x.file}`, x, api, apiBase, notify, refresh: load, relTime, ui, onRemove: remove }))));
|
|
444
505
|
}
|
|
445
506
|
|
|
446
507
|
/** 一个群 / 私聊学到的说法 */
|
package/lib/panel.css
CHANGED
|
@@ -227,6 +227,7 @@ a.fs-btn{text-decoration:none}
|
|
|
227
227
|
.fs-sticker{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px 4px;border:1px solid var(--fs-line);border-radius:6px;background:var(--bg-sidebar)}
|
|
228
228
|
.fs-sticker img{width:64px;height:64px;object-fit:contain}
|
|
229
229
|
.fs-sticker figcaption{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
230
|
+
.fs-sticker figcaption .fs-input{flex:none;width:100%;height:24px;padding:0 4px;font-size:12px;text-align:center}
|
|
230
231
|
.fs-sticker .fs-icon-btn{position:absolute;top:2px;right:2px;opacity:0}
|
|
231
232
|
.fs-sticker:hover .fs-icon-btn,.fs-sticker:focus-within .fs-icon-btn{opacity:1}
|
|
232
233
|
/* 人格页:模板卡片、说话示例气泡、世界书编辑 */
|
package/lib/routes-v07.js
CHANGED
|
@@ -7,22 +7,28 @@
|
|
|
7
7
|
* - 首次引导:检查清单 + 不再显示。
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
-
import { join, basename } from 'node:path';
|
|
10
|
+
import { join, basename, resolve } from 'node:path';
|
|
11
11
|
import { FEATURES } from './llm.js';
|
|
12
12
|
import { voiceConfig } from './voice.js';
|
|
13
13
|
import { stickerConfig } from './stickers.js';
|
|
14
14
|
import { chatKind, slangConfig, GLOBAL_ID } from './slang.js';
|
|
15
15
|
import { WEB_PROVIDERS, webSearchConfig, ownSearchReady } from './websearch.js';
|
|
16
16
|
import { listChatSessions } from './db.js';
|
|
17
|
-
import { previewMaiBotExtras, importMaiBotExtras } from './maibot-extras.js';
|
|
17
|
+
import { previewMaiBotExtras, importMaiBotExtras, sniffImage } from './maibot-extras.js';
|
|
18
18
|
import { maskKey } from './routes.js';
|
|
19
19
|
|
|
20
20
|
const BODY_MAX = 256 * 1024;
|
|
21
|
+
const IMPORT_BODY_MAX = 6 * 1024 * 1024; // 一张图的 base64 + JSON 兜底上限
|
|
22
|
+
const IMPORT_BYTES_MAX = 4 * 1024 * 1024; // 解码后的图片上限
|
|
21
23
|
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
|
|
24
|
+
const MEDIA_TYPES = new Set(Object.values(MIME));
|
|
22
25
|
|
|
23
26
|
const isMasked = (s) => typeof s === 'string' && s.includes('••');
|
|
24
27
|
const str = (x, max = 200) => (typeof x === 'string' ? x.trim().slice(0, max) : '');
|
|
25
28
|
|
|
29
|
+
/** base64 严一点:只认标准字母表 + 合法长度,别的编码直接拒。 */
|
|
30
|
+
const isBase64 = (s) => s.length > 0 && s.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(s);
|
|
31
|
+
|
|
26
32
|
/** 按功能选的模型:只留合法的 { provider, model },空 = 跟 DSH 默认。 */
|
|
27
33
|
function cleanModels(raw) {
|
|
28
34
|
const out = {};
|
|
@@ -78,6 +84,19 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
|
|
|
78
84
|
return JSON.parse(raw || '{}');
|
|
79
85
|
};
|
|
80
86
|
const guard = () => { if (!sameOrigin(req)) { send(403, { ok: false, error: 'forbidden' }); return false; } return true; };
|
|
87
|
+
/** 同一个工作区?路径写法可能不一样(相对 / 绝对),比绝对路径。 */
|
|
88
|
+
const sameWorkspace = (a, b) => {
|
|
89
|
+
const x = String(a ?? ''); const y = String(b ?? '');
|
|
90
|
+
if (x === '' || y === '') return false;
|
|
91
|
+
try { return resolve(x) === resolve(y); } catch { return x === y; }
|
|
92
|
+
};
|
|
93
|
+
/** ws 得是认得的工作区(当前工作区 / 收藏记录里出现过的),不能随便指个目录来读、改、删文件。 */
|
|
94
|
+
const knownWorkspace = (p) => {
|
|
95
|
+
const ws = String(p ?? '');
|
|
96
|
+
if (ws === '') return false;
|
|
97
|
+
if (sameWorkspace(ws, gateway?.workspacePath?.() ?? '')) return true;
|
|
98
|
+
return (stickers?.collected() ?? []).some((x) => sameWorkspace(x.workspacePath, ws));
|
|
99
|
+
};
|
|
81
100
|
const webState = () => {
|
|
82
101
|
const w = webConfig?.() ?? webSearchConfig({});
|
|
83
102
|
return {
|
|
@@ -204,20 +223,18 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
|
|
|
204
223
|
// ── 契约 26~28:表情包 ──
|
|
205
224
|
if (path === '/stickers/state' && method === 'GET') {
|
|
206
225
|
if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
|
|
207
|
-
const items = stickers.collected().map((x) => ({
|
|
208
|
-
file: x.file, workspacePath: x.workspacePath, from: x.from ?? '', summary: x.summary ?? '', at: x.at,
|
|
209
|
-
exists: existsSync(join(x.workspacePath, '_stickers', x.file))
|
|
210
|
-
}));
|
|
211
226
|
const ws = gateway?.workspacePath?.() ?? '';
|
|
227
|
+
const items = stickers.list(ws);
|
|
212
228
|
send(200, { ok: true, config: stickerConfig(getConfig().stickers), items, workspacePath: ws, inWorkspace: ws ? (gateway?.listStickers?.(ws) ?? []).length : 0 });
|
|
213
229
|
return true;
|
|
214
230
|
}
|
|
215
231
|
if (path === '/stickers/file' && method === 'GET') {
|
|
216
232
|
const file = basename(String(url.searchParams.get('file') ?? ''));
|
|
217
233
|
const ws = String(url.searchParams.get('ws') ?? '');
|
|
218
|
-
|
|
234
|
+
// 只有清单里真实存在的才给读(收藏的 + 主人自己放进 _stickers 的)
|
|
235
|
+
const hit = knownWorkspace(ws) ? stickers?.list(ws).find((x) => x.file === file && sameWorkspace(x.workspacePath, ws) && x.exists === true) : null;
|
|
219
236
|
const ext = file.split('.').pop()?.toLowerCase() ?? '';
|
|
220
|
-
const full = hit ? join(
|
|
237
|
+
const full = hit ? join(hit.workspacePath, '_stickers', file) : '';
|
|
221
238
|
if (!hit || !MIME[ext] || !existsSync(full)) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
222
239
|
res.writeHead(200, { 'content-type': MIME[ext], 'cache-control': 'private, max-age=300' });
|
|
223
240
|
res.end(readFileSync(full));
|
|
@@ -233,11 +250,45 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
|
|
|
233
250
|
send(200, { ok: true, config: stickerConfig({ ...(getConfig().stickers ?? {}), ...s }) });
|
|
234
251
|
return true;
|
|
235
252
|
}
|
|
253
|
+
if (path === '/stickers/rename' && post) {
|
|
254
|
+
if (!guard()) return true;
|
|
255
|
+
const b = await body();
|
|
256
|
+
if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
|
|
257
|
+
const ws = String(b.workspacePath ?? '');
|
|
258
|
+
if (!knownWorkspace(ws)) { send(200, { ok: false, error: '没有这张' }); return true; }
|
|
259
|
+
const r = stickers.rename(basename(String(b.file ?? '')), String(b.name ?? ''), ws);
|
|
260
|
+
send(200, r.ok ? { ok: true, file: r.file } : { ok: false, error: r.reason ?? '改名失败' });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
236
263
|
if (path === '/stickers/delete' && post) {
|
|
237
264
|
if (!guard()) return true;
|
|
238
265
|
const b = await body();
|
|
239
|
-
const
|
|
240
|
-
|
|
266
|
+
const ws = String(b.workspacePath ?? '');
|
|
267
|
+
const ok = knownWorkspace(ws) && stickers?.remove(basename(String(b.file ?? '')), ws) === true;
|
|
268
|
+
send(200, ok ? { ok: true } : { ok: false, error: '没有这张' });
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
// 主人从面板导入本地图片:只进当前工作区,不占每天收藏的额度
|
|
272
|
+
if (path === '/stickers/import' && post) {
|
|
273
|
+
if (!guard()) return true;
|
|
274
|
+
if (!stickers) { send(200, { ok: false, error: '表情包没起来' }); return true; }
|
|
275
|
+
const ws = gateway?.workspacePath?.() ?? '';
|
|
276
|
+
if (ws === '') { send(200, { ok: false, error: 'QQ 工作区还没准备好(先连一次 QQ)' }); return true; }
|
|
277
|
+
let raw = '';
|
|
278
|
+
try { raw = await readBody(req); } catch { send(200, { ok: false, error: '请求没读完' }); return true; }
|
|
279
|
+
if (raw.length > IMPORT_BODY_MAX) { send(200, { ok: false, error: '图片太大了(超过 4MB)' }); return true; }
|
|
280
|
+
let b;
|
|
281
|
+
try { b = JSON.parse(raw || '{}'); } catch { send(200, { ok: false, error: '请求格式不对' }); return true; }
|
|
282
|
+
const b64 = String(b.base64 ?? '');
|
|
283
|
+
if (!isBase64(b64)) { send(200, { ok: false, error: '图片编码不对' }); return true; }
|
|
284
|
+
const bytes = Buffer.from(b64, 'base64');
|
|
285
|
+
if (bytes.length === 0) { send(200, { ok: false, error: '没有图片内容' }); return true; }
|
|
286
|
+
if (bytes.length > IMPORT_BYTES_MAX) { send(200, { ok: false, error: '图片太大了(超过 4MB)' }); return true; }
|
|
287
|
+
if (b.mediaType !== undefined && b.mediaType !== '' && !MEDIA_TYPES.has(String(b.mediaType))) { send(200, { ok: false, error: '图片类型不对' }); return true; }
|
|
288
|
+
const mediaType = sniffImage(bytes);
|
|
289
|
+
if (mediaType === null) { send(200, { ok: false, error: '不是支持的图片格式' }); return true; }
|
|
290
|
+
const r = stickers.importImage({ bytes, mediaType, name: String(b.name ?? ''), workspacePath: ws, from: '主人' });
|
|
291
|
+
send(200, r.ok ? { ok: true, file: r.file } : { ok: false, error: r.reason ?? '导入失败', full: r.full === true, dup: r.dup === true });
|
|
241
292
|
return true;
|
|
242
293
|
}
|
|
243
294
|
|
package/lib/stickers.js
CHANGED
|
@@ -4,16 +4,25 @@
|
|
|
4
4
|
* 网关把每轮转进会话的图片(字节 + 是不是表情)记在这里;模型在回复里写一行 [收藏表情:名字],
|
|
5
5
|
* 就把这轮(或最近 10 分钟内)对方发的那张表情存到工作区 _stickers/名字.扩展名,以后能用  发。
|
|
6
6
|
* 同一张图(sha1)只存一次;每天最多存 perDay 张;_stickers 里最多 300 张。
|
|
7
|
+
* _stickers 目录里的文件本身就是本体:主人自己放进去的图也在面板清单里(不写 kv),能改名、能删。
|
|
7
8
|
*/
|
|
8
9
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
11
12
|
import { getKv, setKv } from './db.js';
|
|
12
13
|
|
|
13
14
|
export const COLLECT_RE = /\[(?:收藏表情|存表情)\s*[::]\s*([^\]\n]{1,30})\]/g;
|
|
14
15
|
const RECENT_MS = 10 * 60_000;
|
|
15
16
|
const DIR_CAP = 300;
|
|
16
17
|
const EXT = { 'image/png': 'png', 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
|
18
|
+
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
|
|
19
|
+
|
|
20
|
+
/** 同一个工作区?路径写法可能不一样(相对 / 绝对),比绝对路径。 */
|
|
21
|
+
const sameWs = (a, b) => {
|
|
22
|
+
const x = String(a ?? ''); const y = String(b ?? '');
|
|
23
|
+
if (x === '' || y === '') return false;
|
|
24
|
+
try { return resolve(x) === resolve(y); } catch { return x === y; }
|
|
25
|
+
};
|
|
17
26
|
|
|
18
27
|
/** 回复里的 [收藏表情:名字] 抠出来(发出去的文字里去掉)。 */
|
|
19
28
|
export function parseCollect(text) {
|
|
@@ -60,6 +69,59 @@ export class StickerBox {
|
|
|
60
69
|
return getKv(this.db, 'stickers.collected', []) ?? [];
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 面板清单:收藏记录(含文件已经没了的)+ 当前工作区里主人自己放进 _stickers 的图。
|
|
74
|
+
* 目录里的图不写 kv、不定时扫,面板打开时现算。
|
|
75
|
+
* @returns {{file: string, workspacePath: string, from: string, summary: string, at: number, exists: boolean, tracked: boolean}[]}
|
|
76
|
+
*/
|
|
77
|
+
list(workspacePath) {
|
|
78
|
+
const ws = String(workspacePath ?? '').trim();
|
|
79
|
+
const rows = this.collected();
|
|
80
|
+
const items = rows.map((x) => ({
|
|
81
|
+
file: x.file, workspacePath: x.workspacePath, from: x.from ?? '', summary: x.summary ?? '', at: x.at ?? 0,
|
|
82
|
+
exists: existsSync(join(x.workspacePath, '_stickers', x.file)), tracked: true
|
|
83
|
+
}));
|
|
84
|
+
if (ws === '') return items;
|
|
85
|
+
const known = new Set(rows.filter((x) => sameWs(x.workspacePath, ws)).map((x) => x.file));
|
|
86
|
+
let names = [];
|
|
87
|
+
try { names = readdirSync(join(ws, '_stickers')); } catch { return items; }
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
if (known.has(name) || !IMAGE_EXT.has(extname(name).slice(1).toLowerCase())) continue;
|
|
90
|
+
let at = 0;
|
|
91
|
+
try { at = statSync(join(ws, '_stickers', name)).mtimeMs; } catch { /* 读不到就当没有 */ }
|
|
92
|
+
items.push({ file: name, workspacePath: ws, from: '主人放的', summary: '', at, exists: true, tracked: false });
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 改名字:文件真的在 _stickers 里才改;撞名直接拒绝(不自动加序号);收藏记录里的文件名跟着改,主人自己放的不写 kv。
|
|
99
|
+
* @returns {{ok: boolean, file?: string, reason?: string}}
|
|
100
|
+
*/
|
|
101
|
+
rename(file, newName, workspacePath) {
|
|
102
|
+
const ws = String(workspacePath ?? '');
|
|
103
|
+
const old = basename(String(file ?? ''));
|
|
104
|
+
if (old === '') return { ok: false, reason: '没有这张' };
|
|
105
|
+
const all = this.collected();
|
|
106
|
+
const hit = all.find((x) => x.file === old && sameWs(x.workspacePath, ws));
|
|
107
|
+
const dir = join(hit?.workspacePath ?? ws, '_stickers');
|
|
108
|
+
const from = join(dir, old);
|
|
109
|
+
if (!existsSync(from)) return { ok: false, reason: '没有这张' };
|
|
110
|
+
const ext = extname(old).slice(1).toLowerCase();
|
|
111
|
+
if (!IMAGE_EXT.has(ext)) return { ok: false, reason: '只支持图片文件' };
|
|
112
|
+
const base = stickerName(basename(String(newName ?? '')));
|
|
113
|
+
if (base === '') return { ok: false, reason: '名字不能用' };
|
|
114
|
+
const target = `${base}.${ext}`;
|
|
115
|
+
if (target === old) return { ok: true, file: old };
|
|
116
|
+
if (existsSync(join(dir, target))) return { ok: false, reason: '已经有同名了' };
|
|
117
|
+
try { renameSync(from, join(dir, target)); } catch { return { ok: false, reason: '改名失败' }; }
|
|
118
|
+
if (hit) {
|
|
119
|
+
hit.file = target;
|
|
120
|
+
setKv(this.db, 'stickers.collected', all.slice(0, DIR_CAP));
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, file: target };
|
|
123
|
+
}
|
|
124
|
+
|
|
63
125
|
/**
|
|
64
126
|
* 存最近一张(优先表情)为 名字.扩展名。
|
|
65
127
|
* @returns {{ok: boolean, file?: string, reason?: string}}
|
|
@@ -114,13 +176,17 @@ export class StickerBox {
|
|
|
114
176
|
return { ok: true, file };
|
|
115
177
|
}
|
|
116
178
|
|
|
117
|
-
/**
|
|
179
|
+
/** 设置页删一张:收藏的和主人自己放的都能删;文件已经没了就把收藏记录也删掉。 */
|
|
118
180
|
remove(file, workspacePath) {
|
|
181
|
+
const ws = String(workspacePath ?? '');
|
|
182
|
+
const name = basename(String(file ?? ''));
|
|
183
|
+
if (name === '') return false;
|
|
119
184
|
const all = this.collected();
|
|
120
|
-
const hit = all.find((x) => x.file ===
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
185
|
+
const hit = all.find((x) => x.file === name && sameWs(x.workspacePath, ws));
|
|
186
|
+
const path = join(hit?.workspacePath ?? ws, '_stickers', name);
|
|
187
|
+
if (!hit && !existsSync(path)) return false;
|
|
188
|
+
try { unlinkSync(path); } catch { /* 已经没了 */ }
|
|
189
|
+
if (hit) setKv(this.db, 'stickers.collected', all.filter((x) => x !== hit));
|
|
124
190
|
return true;
|
|
125
191
|
}
|
|
126
192
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-soul",
|
|
3
|
-
"version": "0.15.
|
|
4
|
-
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
|
+
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.15.2) ",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"files": [
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
|
-
"test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/client-lint.mjs && node test/client-panels.mjs && node test/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs",
|
|
65
|
+
"test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/client-lint.mjs && node test/client-panels.mjs && node test/dsh-desktop.mjs && node test/issue-59-interject.mjs && node test/issue-59-diagnose.mjs && node test/sticker-manage.mjs",
|
|
66
66
|
"eval": "node test/eval/persona-eval.mjs"
|
|
67
67
|
}
|
|
68
68
|
}
|