luckytri 1.0.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/.github/SECURITY.md +71 -0
- package/LICENSE +21 -0
- package/README.en.md +250 -0
- package/README.md +250 -0
- package/bin/luckytri.js +107 -0
- package/docs/brand/banner.png +0 -0
- package/docs/brand/palette.png +0 -0
- package/docs/brand/portrait.png +0 -0
- package/docs/en/CHANGELOG.md +113 -0
- package/docs/en/architecture.md +152 -0
- package/docs/en/chat-style.md +62 -0
- package/docs/en/connect.md +63 -0
- package/docs/en/guide.md +216 -0
- package/docs/en/her-life.md +189 -0
- package/docs/en/npm-release.md +25 -0
- package/docs/en/plugins.md +82 -0
- package/docs/en/recovery.md +28 -0
- package/docs/en/time.md +71 -0
- package/docs/en/webui.md +73 -0
- package/docs/zh/CHANGELOG.md +113 -0
- package/docs/zh/architecture.md +152 -0
- package/docs/zh/chat-style.md +62 -0
- package/docs/zh/connect.md +63 -0
- package/docs/zh/guide.md +216 -0
- package/docs/zh/her-life.md +189 -0
- package/docs/zh/npm-release.md +33 -0
- package/docs/zh/plugins.md +82 -0
- package/docs/zh/recovery.md +28 -0
- package/docs/zh/time.md +71 -0
- package/docs/zh/webui.md +73 -0
- package/package.json +108 -0
- package/plugins/sdk/index.d.ts +155 -0
- package/plugins/sdk/testing.js +58 -0
- package/plugins/weather/index.js +82 -0
- package/plugins/weather/luckytri-plugin.json +29 -0
- package/public/app/assets/ChatsPage-C-FcRapR.js +3 -0
- package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
- package/public/app/assets/Empty-CpP5kQHH.js +2 -0
- package/public/app/assets/Empty-DmUB2-Bk.css +1 -0
- package/public/app/assets/Field-D4gY-INU.js +1 -0
- package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
- package/public/app/assets/HeartPage-CPy_thQd.js +1 -0
- package/public/app/assets/LifePage-BUi5euBF.css +1 -0
- package/public/app/assets/LifePage-CxWvwmSf.js +1 -0
- package/public/app/assets/MemoryPage-DC4rhQ68.js +1 -0
- package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
- package/public/app/assets/NaturePage-CTXHCE2D.css +1 -0
- package/public/app/assets/NaturePage-IvYw04Zq.js +3 -0
- package/public/app/assets/PeoplePage-BmUxkrOU.css +1 -0
- package/public/app/assets/PeoplePage-Cqefm9-q.js +1 -0
- package/public/app/assets/PluginsPage-DKSaKvXb.js +2 -0
- package/public/app/assets/PluginsPage-HyBe5D8w.css +1 -0
- package/public/app/assets/SystemPage-CAMPoYqM.css +1 -0
- package/public/app/assets/SystemPage-CJmtJkrK.js +1 -0
- package/public/app/assets/TimePage-BOms_n4I.css +1 -0
- package/public/app/assets/TimePage-Cw44inM4.js +3 -0
- package/public/app/assets/formatters-ZmW_xFHw.js +1 -0
- package/public/app/assets/index-BheRW-1t.js +4 -0
- package/public/app/assets/index-YDz1ree1.css +1 -0
- package/public/app/assets/knowledge-DG0e0uKw.js +1 -0
- package/public/app/assets/liquid-garden-IMoHBwzc.png +0 -0
- package/public/app/assets/luckytri-home-95QKeMtc.png +0 -0
- package/public/app/assets/media-BzPDBm7e.js +1 -0
- package/public/app/index.html +27 -0
- package/public/guide.en.html +1053 -0
- package/public/guide.html +893 -0
- package/public/plugin-kit/v1/bridge.js +30 -0
- package/public/plugin-kit/v1/kit.css +14 -0
- package/public/tutorial.en.md +214 -0
- package/public/tutorial.md +214 -0
- package/scripts/backup.js +311 -0
- package/scripts/browser.js +41 -0
- package/scripts/launch.js +94 -0
- package/scripts/plugin.js +150 -0
- package/scripts/proxy-config.js +167 -0
- package/scripts/recover.js +26 -0
- package/scripts/runtime.js +25 -0
- package/scripts/service.js +47 -0
- package/scripts/setup.js +50 -0
- package/scripts/start-server.js +41 -0
- package/scripts/stop.js +36 -0
- package/server/app.js +96 -0
- package/server/channels/adapters.js +27 -0
- package/server/channels/contract.js +57 -0
- package/server/channels/hub.js +165 -0
- package/server/channels/index.js +25 -0
- package/server/channels/onebot/adapter.js +288 -0
- package/server/channels/onebot/index.js +336 -0
- package/server/channels/onebot/token.js +3 -0
- package/server/channels/qqbot/adapter.js +253 -0
- package/server/channels/qqbot/api.js +199 -0
- package/server/channels/qqbot/credentials.js +11 -0
- package/server/channels/qqbot/gateway.js +274 -0
- package/server/channels/qqbot/index.js +389 -0
- package/server/channels/segments.js +40 -0
- package/server/channels/session-key.js +153 -0
- package/server/channels/state.js +42 -0
- package/server/core/api.js +564 -0
- package/server/core/capabilities.js +208 -0
- package/server/core/context-builder.js +288 -0
- package/server/core/context-compactor.js +454 -0
- package/server/core/conversation-cues.js +325 -0
- package/server/core/conversation-manager.js +87 -0
- package/server/core/events.js +60 -0
- package/server/core/feedback.js +8 -0
- package/server/core/group-style.js +72 -0
- package/server/core/image-preprocessor.js +208 -0
- package/server/core/initiative-context.js +66 -0
- package/server/core/input-budget.js +32 -0
- package/server/core/local-demo.js +98 -0
- package/server/core/message-manager.js +118 -0
- package/server/core/message-scheduler.js +78 -0
- package/server/core/model-json.js +190 -0
- package/server/core/model-manager.js +1213 -0
- package/server/core/model-presets.js +900 -0
- package/server/core/name-call.js +29 -0
- package/server/core/net-guard.js +49 -0
- package/server/core/network.js +157 -0
- package/server/core/orchestrator.js +569 -0
- package/server/core/perceivers.js +91 -0
- package/server/core/person-name.js +22 -0
- package/server/core/persona-manager.js +243 -0
- package/server/core/reply-delivery.js +393 -0
- package/server/core/reply-target-resolver.js +50 -0
- package/server/core/repository.js +293 -0
- package/server/core/response-generator.js +50 -0
- package/server/core/response-validator.js +508 -0
- package/server/core/retired-prompts.js +51 -0
- package/server/core/sessions.js +109 -0
- package/server/core/speaker-names.js +92 -0
- package/server/core/topic-tracker.js +65 -0
- package/server/core/turn-processor.js +630 -0
- package/server/core/turn.js +236 -0
- package/server/core/vision-manager.js +633 -0
- package/server/http.js +18 -0
- package/server/i18n/decisions.js +206 -0
- package/server/i18n/errors.js +497 -0
- package/server/i18n/index.js +144 -0
- package/server/i18n/labels.js +653 -0
- package/server/i18n/models.js +111 -0
- package/server/index.js +102 -0
- package/server/knowledge/api.js +342 -0
- package/server/knowledge/embedding-profile.js +114 -0
- package/server/knowledge/index.js +13 -0
- package/server/knowledge/manager.js +386 -0
- package/server/knowledge/retrieval.js +93 -0
- package/server/knowledge/schema.js +154 -0
- package/server/knowledge/vector-index.js +90 -0
- package/server/mind/affect.js +265 -0
- package/server/mind/anticipations.js +507 -0
- package/server/mind/api.js +703 -0
- package/server/mind/attention.js +200 -0
- package/server/mind/bonds.js +558 -0
- package/server/mind/budget.js +152 -0
- package/server/mind/clock.js +83 -0
- package/server/mind/context-selection.js +61 -0
- package/server/mind/continuity.js +562 -0
- package/server/mind/conversation-origin.js +175 -0
- package/server/mind/day-planner.js +324 -0
- package/server/mind/days.js +210 -0
- package/server/mind/evidence-read.js +45 -0
- package/server/mind/evidence.js +148 -0
- package/server/mind/faces.js +138 -0
- package/server/mind/guard.js +60 -0
- package/server/mind/index.js +445 -0
- package/server/mind/initiative.js +333 -0
- package/server/mind/life/activities.js +21 -0
- package/server/mind/life/context.js +309 -0
- package/server/mind/life/diary.js +786 -0
- package/server/mind/life/growth.js +252 -0
- package/server/mind/life/index.js +1049 -0
- package/server/mind/life/presence.js +305 -0
- package/server/mind/meetings.js +1069 -0
- package/server/mind/memory-policy.js +48 -0
- package/server/mind/memory.js +888 -0
- package/server/mind/nature.js +262 -0
- package/server/mind/novelty.js +19 -0
- package/server/mind/observations.js +84 -0
- package/server/mind/own-day.js +324 -0
- package/server/mind/own-voice.js +318 -0
- package/server/mind/periods.js +118 -0
- package/server/mind/reading.js +120 -0
- package/server/mind/salience.js +201 -0
- package/server/mind/schema.js +562 -0
- package/server/mind/self.js +484 -0
- package/server/mind/senses.js +53 -0
- package/server/mind/thoughts.js +274 -0
- package/server/mind/time/activity-clock.js +196 -0
- package/server/mind/time/agenda.js +242 -0
- package/server/mind/time/api.js +200 -0
- package/server/mind/time/attention.js +34 -0
- package/server/mind/time/availability.js +69 -0
- package/server/mind/time/commitment-review.js +198 -0
- package/server/mind/time/delivery-links.js +130 -0
- package/server/mind/time/executor.js +512 -0
- package/server/mind/time/experiences.js +128 -0
- package/server/mind/time/games.js +562 -0
- package/server/mind/time/index.js +739 -0
- package/server/mind/time/intent.js +166 -0
- package/server/mind/time/kinds.js +171 -0
- package/server/mind/time/model-material.js +77 -0
- package/server/mind/time/plugin-activity.js +207 -0
- package/server/mind/time/presentation.js +51 -0
- package/server/mind/time/schedule.js +83 -0
- package/server/mind/time/schema.js +84 -0
- package/server/mind/time/search.js +383 -0
- package/server/mind/time/sharing.js +472 -0
- package/server/mind/time/task-links.js +311 -0
- package/server/mind/time/tasks.js +587 -0
- package/server/mind/time/works.js +432 -0
- package/server/mind/traits.js +191 -0
- package/server/mind/util.js +110 -0
- package/server/mind/view.js +273 -0
- package/server/paths.js +102 -0
- package/server/plugins/api.js +204 -0
- package/server/plugins/host.js +967 -0
- package/server/plugins/install.js +295 -0
- package/server/plugins/manifest.js +201 -0
- package/server/plugins/permissions.js +42 -0
- package/server/plugins/runner/guard.mjs +36 -0
- package/server/plugins/runner/worker.mjs +250 -0
- package/server/plugins/schema.js +91 -0
- package/server/plugins/zip.js +112 -0
- package/server/startup-lock.js +118 -0
- package/server/storage/archive.js +241 -0
- package/server/storage/backup-cleanup-worker.js +8 -0
- package/server/storage/backup-cleanup.js +414 -0
- package/server/storage/backup-lock.js +76 -0
- package/server/storage/backup-scheduler.js +131 -0
- package/server/storage/store.js +199 -0
- package/server/studio/display-names.js +138 -0
- package/server/studio/management.js +102 -0
- package/server/studio/readiness.js +190 -0
- package/server/studio/settings.js +236 -0
- package/server/version.js +10 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
3
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { assertPublicHost } from "./net-guard.js";
|
|
6
|
+
import { adapterFor } from "../channels/adapters.js";
|
|
7
|
+
import {
|
|
8
|
+
imageMime,
|
|
9
|
+
prepareVisionImage,
|
|
10
|
+
visionImageLimits,
|
|
11
|
+
withVisionImageSlot,
|
|
12
|
+
} from "./image-preprocessor.js";
|
|
13
|
+
|
|
14
|
+
export { imageMime };
|
|
15
|
+
const KNOWN_REASONS = new Set([
|
|
16
|
+
"图片地址不可访问",
|
|
17
|
+
"图片超过大小限制",
|
|
18
|
+
"不是支持的图片格式",
|
|
19
|
+
"本地图片不存在",
|
|
20
|
+
"本地图片无法读取",
|
|
21
|
+
"图片下载超时",
|
|
22
|
+
"图片下载失败",
|
|
23
|
+
"图片跳转过多",
|
|
24
|
+
"图片跳转无效",
|
|
25
|
+
"图片读取失败",
|
|
26
|
+
"图片缓存不可用",
|
|
27
|
+
"图片像素超过处理限制",
|
|
28
|
+
"图片处理超时",
|
|
29
|
+
"QQ 图片读取超时",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const IMAGE_REQUEST =
|
|
33
|
+
/这张图|这个图|这图|看图|图里|图中|截图|什么图|这是什么|帮我看|看看这|看一下这|看下这|上面那张|下面那张|图上写|画面里/;
|
|
34
|
+
const isVisualAttachment = (item) =>
|
|
35
|
+
["image", "mface", "market_face", "sticker"].includes(item?.type);
|
|
36
|
+
|
|
37
|
+
export function wantsImageLook(message) {
|
|
38
|
+
if (!message) return false;
|
|
39
|
+
if (
|
|
40
|
+
message.mentioned === true ||
|
|
41
|
+
message.raw?.mentioned === true ||
|
|
42
|
+
(message.accountId &&
|
|
43
|
+
(message.mentions || []).map(String).includes(String(message.accountId)))
|
|
44
|
+
)
|
|
45
|
+
return true;
|
|
46
|
+
if (IMAGE_REQUEST.test(String(message.text || ""))) return true;
|
|
47
|
+
return (
|
|
48
|
+
message.kind === "private" &&
|
|
49
|
+
(message.attachments || []).some(isVisualAttachment)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lookFocus(rows, batchIds) {
|
|
54
|
+
if (!Array.isArray(batchIds)) return null;
|
|
55
|
+
const batch = new Set(batchIds);
|
|
56
|
+
const triggers = rows.filter(
|
|
57
|
+
(message) => batch.has(message.seq) && wantsImageLook(message),
|
|
58
|
+
);
|
|
59
|
+
if (!triggers.length) return new Set();
|
|
60
|
+
const focus = new Set();
|
|
61
|
+
const indexBySeq = new Map(
|
|
62
|
+
rows.map((message, index) => [message.seq, index]),
|
|
63
|
+
);
|
|
64
|
+
for (const message of triggers) {
|
|
65
|
+
focus.add(message.seq);
|
|
66
|
+
const index = indexBySeq.get(message.seq);
|
|
67
|
+
const quoted = message.replyTo?.seq ?? message.replyChain?.[0];
|
|
68
|
+
if (quoted != null) focus.add(quoted);
|
|
69
|
+
// A new image may simply be a reaction to another topic. Never pull in
|
|
70
|
+
// neighbouring images merely because someone addressed her nearby.
|
|
71
|
+
if (
|
|
72
|
+
index != null &&
|
|
73
|
+
!(message.attachments || []).some(isVisualAttachment) &&
|
|
74
|
+
quoted == null
|
|
75
|
+
) {
|
|
76
|
+
const previous = rows[index - 1];
|
|
77
|
+
if (
|
|
78
|
+
previous?.userId === message.userId &&
|
|
79
|
+
(previous.attachments || []).some(isVisualAttachment) &&
|
|
80
|
+
(!Number.isFinite(message.time) ||
|
|
81
|
+
!Number.isFinite(previous.time) ||
|
|
82
|
+
message.time - previous.time <= 90000)
|
|
83
|
+
)
|
|
84
|
+
focus.add(previous.seq);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return focus;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function visionInputs(snapshot, profile, { selective = false } = {}) {
|
|
91
|
+
const adapter = adapterFor(snapshot.sessionId || "onebot");
|
|
92
|
+
const usable = (url) => adapter.usableMediaUrl(url);
|
|
93
|
+
const images = [],
|
|
94
|
+
unavailable = [];
|
|
95
|
+
const rows = snapshot.sourceRows || [];
|
|
96
|
+
let focus = null;
|
|
97
|
+
if (Array.isArray(snapshot.batchIds)) {
|
|
98
|
+
focus = selective
|
|
99
|
+
? lookFocus(rows, snapshot.batchIds)
|
|
100
|
+
: new Set(snapshot.batchIds);
|
|
101
|
+
if (!selective)
|
|
102
|
+
for (const message of rows)
|
|
103
|
+
if (focus.has(message.seq))
|
|
104
|
+
for (const id of message.replyChain || []) focus.add(id);
|
|
105
|
+
if (!focus.size) return { images, unavailable };
|
|
106
|
+
}
|
|
107
|
+
const seenOnMessage = new Map();
|
|
108
|
+
for (const m of snapshot.sourceRows || [])
|
|
109
|
+
for (const a of m.attachments || []) {
|
|
110
|
+
if (!isVisualAttachment(a)) continue;
|
|
111
|
+
if (focus && !focus.has(m.seq)) continue;
|
|
112
|
+
if (!profile?.vision) {
|
|
113
|
+
unavailable.push({ messageId: m.seq, reason: "模型未开启视觉能力" });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const source = imageSource(a, usable);
|
|
117
|
+
if (!source) {
|
|
118
|
+
unavailable.push({ messageId: m.seq, reason: "图片地址不可用" });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (images.length >= 4) {
|
|
122
|
+
unavailable.push({ messageId: m.seq, reason: "超出本轮四张视觉预算" });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const index = seenOnMessage.get(m.seq) || 0;
|
|
126
|
+
seenOnMessage.set(m.seq, index + 1);
|
|
127
|
+
images.push({
|
|
128
|
+
messageId: m.seq,
|
|
129
|
+
speaker: m.userId,
|
|
130
|
+
index,
|
|
131
|
+
kind: isStickerAttachment(a) ? "sticker" : "image",
|
|
132
|
+
summary: String(a.summary || "").slice(0, 80),
|
|
133
|
+
...source,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { images, unavailable };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isStickerAttachment(attachment) {
|
|
140
|
+
return Boolean(
|
|
141
|
+
["mface", "market_face", "sticker"].includes(attachment.type) ||
|
|
142
|
+
attachment.emoji_id ||
|
|
143
|
+
attachment.emoji_package_id ||
|
|
144
|
+
(attachment.summary && attachment.key) ||
|
|
145
|
+
["sticker", "face", "mface"].includes(
|
|
146
|
+
String(attachment.sub_type || attachment.subType || "").toLowerCase(),
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isLocalPath(value) {
|
|
152
|
+
return (
|
|
153
|
+
typeof value === "string" &&
|
|
154
|
+
(value.startsWith("file://") ||
|
|
155
|
+
/^[a-zA-Z]:[\\/]/.test(value) ||
|
|
156
|
+
(value.startsWith("/") && !value.startsWith("//")))
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fileId(value) {
|
|
161
|
+
if (typeof value !== "string") return "";
|
|
162
|
+
const text = value.trim();
|
|
163
|
+
if (!text || text.length > 300 || /[\\/]|\.\./.test(text)) return "";
|
|
164
|
+
if (/^(https?:|data:|base64:|file:)/i.test(text) || isLocalPath(text))
|
|
165
|
+
return "";
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function classify(values, usable) {
|
|
170
|
+
const present = values.filter(
|
|
171
|
+
(value) => typeof value === "string" && value.trim(),
|
|
172
|
+
);
|
|
173
|
+
return {
|
|
174
|
+
url: present.find((value) => usable(value)) || "",
|
|
175
|
+
inline:
|
|
176
|
+
present.find((value) => /^(data:image\/|base64:\/\/)/i.test(value)) || "",
|
|
177
|
+
local: present.find((value) => isLocalPath(value)) || "",
|
|
178
|
+
file: present.find((value) => fileId(value)) || "",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function imageSource(attachment, usable) {
|
|
183
|
+
const source = classify(
|
|
184
|
+
[attachment.url, attachment.file, attachment.inline],
|
|
185
|
+
usable,
|
|
186
|
+
);
|
|
187
|
+
if (!source.url && !source.inline && !source.local && !source.file)
|
|
188
|
+
return null;
|
|
189
|
+
return source;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sourceFields(image, usable) {
|
|
193
|
+
return classify([image.url, image.inline, image.local, image.file], usable);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function header(response, name) {
|
|
197
|
+
const headers = response?.headers;
|
|
198
|
+
if (!headers) return "";
|
|
199
|
+
if (typeof headers.get === "function") return headers.get(name) || "";
|
|
200
|
+
return headers[name] || headers[name.toLowerCase()] || "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function readLimited(response, maxBytes) {
|
|
204
|
+
const reader = response.body?.getReader?.();
|
|
205
|
+
if (!reader) {
|
|
206
|
+
if (typeof response.arrayBuffer !== "function") throw Error("图片下载失败");
|
|
207
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
208
|
+
if (bytes.length > maxBytes) throw Error("图片超过大小限制");
|
|
209
|
+
return bytes;
|
|
210
|
+
}
|
|
211
|
+
const chunks = [];
|
|
212
|
+
let total = 0;
|
|
213
|
+
while (true) {
|
|
214
|
+
const { done, value } = await reader.read();
|
|
215
|
+
if (done) break;
|
|
216
|
+
total += value.byteLength;
|
|
217
|
+
if (total > maxBytes) {
|
|
218
|
+
await reader.cancel().catch(() => {});
|
|
219
|
+
throw Error("图片超过大小限制");
|
|
220
|
+
}
|
|
221
|
+
chunks.push(Buffer.from(value));
|
|
222
|
+
}
|
|
223
|
+
return Buffer.concat(chunks);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function downloadImage(
|
|
227
|
+
startUrl,
|
|
228
|
+
{ fetch: fetcher, lookup, usable, imageLimits },
|
|
229
|
+
) {
|
|
230
|
+
let current = startUrl;
|
|
231
|
+
const signal = AbortSignal.timeout(imageLimits.downloadTimeoutMs);
|
|
232
|
+
for (let hop = 0; hop < 3; hop++) {
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = new URL(current);
|
|
236
|
+
} catch {
|
|
237
|
+
throw Error("图片地址不可访问");
|
|
238
|
+
}
|
|
239
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password)
|
|
240
|
+
throw Error("图片地址不可访问");
|
|
241
|
+
if (!usable(current)) throw Error("图片地址不可访问");
|
|
242
|
+
await assertPublicHost(parsed.hostname, lookup, "图片地址不可访问");
|
|
243
|
+
let response;
|
|
244
|
+
try {
|
|
245
|
+
response = await fetcher(current, {
|
|
246
|
+
redirect: "manual",
|
|
247
|
+
headers: {
|
|
248
|
+
"User-Agent":
|
|
249
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
250
|
+
Referer: "https://qun.qq.com/",
|
|
251
|
+
Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,*/*",
|
|
252
|
+
},
|
|
253
|
+
signal,
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError")
|
|
257
|
+
throw Error("图片下载超时");
|
|
258
|
+
throw Error("图片下载失败");
|
|
259
|
+
}
|
|
260
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
261
|
+
await response.body?.cancel?.().catch(() => {});
|
|
262
|
+
const location = header(response, "location");
|
|
263
|
+
if (!location) throw Error("图片跳转无效");
|
|
264
|
+
current = new URL(location, current).href;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (response.status < 200 || response.status >= 300) {
|
|
268
|
+
await response.body?.cancel?.().catch(() => {});
|
|
269
|
+
throw Error(`图片下载失败 HTTP ${response.status}`);
|
|
270
|
+
}
|
|
271
|
+
const declared = Number(header(response, "content-length"));
|
|
272
|
+
if (Number.isFinite(declared) && declared > imageLimits.maxSourceBytes) {
|
|
273
|
+
await response.body?.cancel?.().catch(() => {});
|
|
274
|
+
throw Error("图片超过大小限制");
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
return await readLimited(response, imageLimits.maxSourceBytes);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (
|
|
280
|
+
signal.aborted ||
|
|
281
|
+
["TimeoutError", "AbortError"].includes(error?.name)
|
|
282
|
+
)
|
|
283
|
+
throw Error("图片下载超时");
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
throw Error("图片跳转过多");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function inlineBytes(value, maxBytes) {
|
|
291
|
+
const text = String(value || "");
|
|
292
|
+
if (text.length > Math.ceil(maxBytes / 3) * 4 * 1.05 + 128)
|
|
293
|
+
throw Error("图片超过大小限制");
|
|
294
|
+
const raw =
|
|
295
|
+
text.match(/^data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=\r\n]+)$/i)?.[1] ||
|
|
296
|
+
text.match(/^base64:\/\/([a-z0-9+/=\r\n]+)$/i)?.[1];
|
|
297
|
+
if (!raw) throw Error("图片读取失败");
|
|
298
|
+
const bytes = Buffer.from(raw, "base64");
|
|
299
|
+
if (!bytes.length || bytes.length > maxBytes) throw Error("图片超过大小限制");
|
|
300
|
+
return bytes;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function readLocalImage(value, fs, maxBytes) {
|
|
304
|
+
let resolved;
|
|
305
|
+
try {
|
|
306
|
+
const path = String(value).startsWith("file://")
|
|
307
|
+
? fileURLToPath(value)
|
|
308
|
+
: String(value);
|
|
309
|
+
resolved = await fs.realpath(path);
|
|
310
|
+
} catch {
|
|
311
|
+
throw Error("本地图片不存在");
|
|
312
|
+
}
|
|
313
|
+
let info;
|
|
314
|
+
try {
|
|
315
|
+
info = await fs.stat(resolved);
|
|
316
|
+
} catch {
|
|
317
|
+
throw Error("本地图片无法读取");
|
|
318
|
+
}
|
|
319
|
+
if (!info.isFile() || info.size <= 0) throw Error("本地图片无法读取");
|
|
320
|
+
if (info.size > maxBytes) throw Error("图片超过大小限制");
|
|
321
|
+
const bytes = await fs.readFile(resolved);
|
|
322
|
+
if (bytes.length > maxBytes) throw Error("图片超过大小限制");
|
|
323
|
+
return bytes;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function safeReason(error) {
|
|
327
|
+
const message = String(error?.message || "");
|
|
328
|
+
if (KNOWN_REASONS.has(message)) return message;
|
|
329
|
+
if (/^图片下载失败 HTTP \d{3}$/.test(message)) return message;
|
|
330
|
+
if (/timeout|aborted|AbortError/i.test(message)) return "图片下载超时";
|
|
331
|
+
if (/ENOENT/i.test(message)) return "本地图片不存在";
|
|
332
|
+
if (/EACCES|EPERM/i.test(message)) return "本地图片无法读取";
|
|
333
|
+
return "图片读取失败";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function tryStep(fn, errors) {
|
|
337
|
+
try {
|
|
338
|
+
return await fn();
|
|
339
|
+
} catch (error) {
|
|
340
|
+
errors.push(error);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function resolveBytes(image, options, depth) {
|
|
346
|
+
const source = sourceFields(image, options.usable);
|
|
347
|
+
const errors = [];
|
|
348
|
+
if (source.inline) {
|
|
349
|
+
const bytes = await tryStep(
|
|
350
|
+
() => inlineBytes(source.inline, options.imageLimits.maxSourceBytes),
|
|
351
|
+
errors,
|
|
352
|
+
);
|
|
353
|
+
if (bytes) return bytes;
|
|
354
|
+
}
|
|
355
|
+
if (source.local) {
|
|
356
|
+
const bytes = await tryStep(
|
|
357
|
+
() =>
|
|
358
|
+
readLocalImage(
|
|
359
|
+
source.local,
|
|
360
|
+
options.fs,
|
|
361
|
+
options.imageLimits.maxSourceBytes,
|
|
362
|
+
),
|
|
363
|
+
errors,
|
|
364
|
+
);
|
|
365
|
+
if (bytes) return bytes;
|
|
366
|
+
}
|
|
367
|
+
if (source.url) {
|
|
368
|
+
const bytes = await tryStep(
|
|
369
|
+
() => downloadImage(source.url, options),
|
|
370
|
+
errors,
|
|
371
|
+
);
|
|
372
|
+
if (bytes) return bytes;
|
|
373
|
+
}
|
|
374
|
+
if (depth === 0 && source.file && options.fetchImage) {
|
|
375
|
+
const fresh = await tryStep(
|
|
376
|
+
() => options.fetchImage(source.file, options.sessionId),
|
|
377
|
+
errors,
|
|
378
|
+
);
|
|
379
|
+
if (fresh && typeof fresh === "object") {
|
|
380
|
+
const inline = fresh.base64
|
|
381
|
+
? /^(?:data:|base64:\/\/)/i.test(fresh.base64)
|
|
382
|
+
? fresh.base64
|
|
383
|
+
: "base64://" + fresh.base64
|
|
384
|
+
: "";
|
|
385
|
+
try {
|
|
386
|
+
return await resolveBytes(
|
|
387
|
+
{ url: fresh.url || "", file: fresh.file || "", inline },
|
|
388
|
+
options,
|
|
389
|
+
1,
|
|
390
|
+
);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
errors.push(error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
throw errors.at(-1) || Error("图片读取失败");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function loadVisionImages(images, options = {}) {
|
|
400
|
+
const adapter = adapterFor(options.sessionId || "onebot");
|
|
401
|
+
const resolved = {
|
|
402
|
+
usable: options.usable || ((url) => adapter.usableMediaUrl(url)),
|
|
403
|
+
fetch: options.fetch || fetch,
|
|
404
|
+
lookup: options.lookup || dnsLookup,
|
|
405
|
+
fs: options.fs || { realpath, readFile, stat },
|
|
406
|
+
fetchImage: options.fetchImage,
|
|
407
|
+
sessionId: options.sessionId,
|
|
408
|
+
imageLimits: { ...visionImageLimits(), ...options.imageLimits },
|
|
409
|
+
};
|
|
410
|
+
const results = await Promise.all(
|
|
411
|
+
(images || []).map((image) =>
|
|
412
|
+
withVisionImageSlot(async () => {
|
|
413
|
+
try {
|
|
414
|
+
const bytes = await resolveBytes(image, resolved, 0);
|
|
415
|
+
const prepared = await prepareVisionImage(
|
|
416
|
+
bytes,
|
|
417
|
+
resolved.imageLimits,
|
|
418
|
+
);
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
image: {
|
|
422
|
+
messageId: image.messageId,
|
|
423
|
+
speaker: image.speaker,
|
|
424
|
+
index: image.index ?? 0,
|
|
425
|
+
kind: image.kind || "image",
|
|
426
|
+
summary: image.summary || "",
|
|
427
|
+
file: image.file || "",
|
|
428
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
429
|
+
frameOnly: prepared.frameOnly,
|
|
430
|
+
url: `data:${prepared.mime};base64,${prepared.bytes.toString("base64")}`,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
unavailable: {
|
|
437
|
+
messageId: image.messageId,
|
|
438
|
+
reason: safeReason(error),
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}),
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
return {
|
|
446
|
+
images: results.filter((item) => item.ok).map((item) => item.image),
|
|
447
|
+
unavailable: results
|
|
448
|
+
.filter((item) => !item.ok)
|
|
449
|
+
.map((item) => item.unavailable),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function visionCacheKeys(sessionId, image) {
|
|
454
|
+
const keys = [];
|
|
455
|
+
const index = image?.index ?? 0;
|
|
456
|
+
if (sessionId && image?.messageId != null)
|
|
457
|
+
keys.push(`msg:v2:${sessionId}:${image.messageId}:${index}`);
|
|
458
|
+
if (image?.file) keys.push(`file:v2:${sessionId}:${image.file}`);
|
|
459
|
+
if (image?.sha256) keys.push(`sha256:v2:${image.sha256}`);
|
|
460
|
+
return keys;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function cachePlaceholders(keys) {
|
|
464
|
+
return keys.map(() => "?").join(",");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function recallVision(db, keys) {
|
|
468
|
+
if (!keys?.length) return "";
|
|
469
|
+
const row = db
|
|
470
|
+
.prepare(
|
|
471
|
+
`SELECT description FROM core_vision_cache WHERE cache_key IN (${cachePlaceholders(keys)}) AND description <> '' LIMIT 1`,
|
|
472
|
+
)
|
|
473
|
+
.get(...keys);
|
|
474
|
+
return row?.description || "";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function visionSeen(db, keys) {
|
|
478
|
+
if (!keys?.length) return false;
|
|
479
|
+
return !!db
|
|
480
|
+
.prepare(
|
|
481
|
+
`SELECT 1 AS n FROM core_vision_cache WHERE cache_key IN (${cachePlaceholders(keys)}) LIMIT 1`,
|
|
482
|
+
)
|
|
483
|
+
.get(...keys);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function saveVision(db, sessionId, pairs) {
|
|
487
|
+
const rows = (pairs || []).filter((row) => row?.description && row.image);
|
|
488
|
+
if (!rows.length) return;
|
|
489
|
+
const stmt = db.prepare(
|
|
490
|
+
`INSERT INTO core_vision_cache(cache_key, session_id, message_id, description, created)
|
|
491
|
+
VALUES (?,?,?,?,?)
|
|
492
|
+
ON CONFLICT(cache_key) DO UPDATE SET description=excluded.description, created=excluded.created`,
|
|
493
|
+
);
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
for (const row of rows)
|
|
496
|
+
for (const key of visionCacheKeys(sessionId, row.image))
|
|
497
|
+
stmt.run(
|
|
498
|
+
key,
|
|
499
|
+
sessionId || "",
|
|
500
|
+
row.image.messageId ?? null,
|
|
501
|
+
row.description,
|
|
502
|
+
now,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function markVisionSeen(db, sessionId, images) {
|
|
507
|
+
const stmt = db.prepare(
|
|
508
|
+
"INSERT OR IGNORE INTO core_vision_cache(cache_key, session_id, message_id, description, created) VALUES (?,?,?,'',?)",
|
|
509
|
+
);
|
|
510
|
+
const now = Date.now();
|
|
511
|
+
for (const image of images || [])
|
|
512
|
+
for (const key of visionCacheKeys(sessionId, image))
|
|
513
|
+
stmt.run(key, sessionId || "", image.messageId ?? null, now);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function classifyVision(db, sessionId, images) {
|
|
517
|
+
const cached = [];
|
|
518
|
+
const pending = [];
|
|
519
|
+
for (const image of images || []) {
|
|
520
|
+
const description = recallVision(db, visionCacheKeys(sessionId, image));
|
|
521
|
+
if (description) cached.push({ messageId: image.messageId, description });
|
|
522
|
+
else pending.push(image);
|
|
523
|
+
}
|
|
524
|
+
return { cached, pending };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function classifyLoaded(
|
|
528
|
+
db,
|
|
529
|
+
sessionId,
|
|
530
|
+
images,
|
|
531
|
+
{ batchIds, direct, modelCanSee },
|
|
532
|
+
) {
|
|
533
|
+
const cached = [];
|
|
534
|
+
const describe = [];
|
|
535
|
+
const show = [];
|
|
536
|
+
const batch = new Set(batchIds || []);
|
|
537
|
+
for (const image of images || []) {
|
|
538
|
+
const keys = visionCacheKeys(sessionId, image);
|
|
539
|
+
const description = recallVision(db, keys);
|
|
540
|
+
if (description) {
|
|
541
|
+
cached.push({ messageId: image.messageId, description });
|
|
542
|
+
saveVision(db, sessionId, [{ image, description }]);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const quoted = !batch.has(image.messageId);
|
|
546
|
+
if (visionSeen(db, keys) || quoted || !direct || !modelCanSee)
|
|
547
|
+
describe.push(image);
|
|
548
|
+
else show.push(image);
|
|
549
|
+
}
|
|
550
|
+
return { cached, describe, show };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export function descriptionsFor(images, result) {
|
|
554
|
+
const rows = Array.isArray(result?.observations) ? result.observations : [];
|
|
555
|
+
const byMessage = new Map();
|
|
556
|
+
for (const row of rows) {
|
|
557
|
+
if (!row || typeof row.description !== "string" || !row.description.trim())
|
|
558
|
+
continue;
|
|
559
|
+
const description = row.description.trim().slice(0, 1000);
|
|
560
|
+
const key = String(row.messageId);
|
|
561
|
+
if (!byMessage.has(key)) byMessage.set(key, []);
|
|
562
|
+
byMessage.get(key).push(description);
|
|
563
|
+
}
|
|
564
|
+
const paired = [];
|
|
565
|
+
const used = new Map();
|
|
566
|
+
for (const image of images || []) {
|
|
567
|
+
const list = byMessage.get(String(image.messageId)) || [];
|
|
568
|
+
const n = used.get(String(image.messageId)) || 0;
|
|
569
|
+
used.set(String(image.messageId), n + 1);
|
|
570
|
+
const description = list[n] || list[0];
|
|
571
|
+
if (description) paired.push({ image, description });
|
|
572
|
+
}
|
|
573
|
+
return paired;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function mergeVision(snapshot, observations) {
|
|
577
|
+
const prev = Array.isArray(snapshot.vision?.observations)
|
|
578
|
+
? snapshot.vision.observations
|
|
579
|
+
: [];
|
|
580
|
+
const next = [...prev];
|
|
581
|
+
const seen = new Set(
|
|
582
|
+
next.map((item) => `${item.messageId}\n${item.description}`),
|
|
583
|
+
);
|
|
584
|
+
for (const item of observations || []) {
|
|
585
|
+
if (!item?.description) continue;
|
|
586
|
+
const key = `${item.messageId}\n${item.description}`;
|
|
587
|
+
if (seen.has(key)) continue;
|
|
588
|
+
seen.add(key);
|
|
589
|
+
next.push({
|
|
590
|
+
messageId: item.messageId,
|
|
591
|
+
description: String(item.description).slice(0, 1000),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
if (next.length) snapshot.vision = { observations: next };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function compactMessage(message) {
|
|
598
|
+
return {
|
|
599
|
+
id: message.id,
|
|
600
|
+
speaker: message.speaker,
|
|
601
|
+
name: message.name,
|
|
602
|
+
role: message.role,
|
|
603
|
+
text: message.text,
|
|
604
|
+
replyTo: message.replyTo,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function visionWindow(messages, focusIds) {
|
|
609
|
+
const list = messages || [];
|
|
610
|
+
const focus = new Set(focusIds || []);
|
|
611
|
+
for (const message of list)
|
|
612
|
+
if (focus.has(message.id))
|
|
613
|
+
for (const id of message.replyChain || []) focus.add(id);
|
|
614
|
+
const focusIndexes = [];
|
|
615
|
+
const near = new Set();
|
|
616
|
+
list.forEach((message, index) => {
|
|
617
|
+
if (!focus.has(message.id)) return;
|
|
618
|
+
focusIndexes.push(index);
|
|
619
|
+
for (
|
|
620
|
+
let cursor = Math.max(0, index - 6);
|
|
621
|
+
cursor <= Math.min(list.length - 1, index + 2);
|
|
622
|
+
cursor++
|
|
623
|
+
)
|
|
624
|
+
near.add(cursor);
|
|
625
|
+
});
|
|
626
|
+
if (!focusIndexes.length) return list.slice(-12).map(compactMessage);
|
|
627
|
+
const limited = [...near].sort((a, b) => a - b);
|
|
628
|
+
const kept = new Set(limited.length > 24 ? limited.slice(-24) : limited);
|
|
629
|
+
for (const index of focusIndexes) kept.add(index);
|
|
630
|
+
return [...kept]
|
|
631
|
+
.sort((a, b) => a - b)
|
|
632
|
+
.map((index) => compactMessage(list[index]));
|
|
633
|
+
}
|
package/server/http.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const wrap = (fn) => async (req, res) => {
|
|
4
|
+
try {
|
|
5
|
+
await fn(req, res);
|
|
6
|
+
} catch (e) {
|
|
7
|
+
res.status(e.status || 400).json({ error: e.message });
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function tokenEqual(a, b) {
|
|
12
|
+
return (
|
|
13
|
+
typeof a === "string" &&
|
|
14
|
+
typeof b === "string" &&
|
|
15
|
+
Buffer.byteLength(a) === Buffer.byteLength(b) &&
|
|
16
|
+
timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
|
17
|
+
);
|
|
18
|
+
}
|