koishi-plugin-dify-ai 0.1.5 → 0.1.7
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/lib/api/client.d.ts +0 -1
- package/lib/config.d.ts +3 -2
- package/lib/history.d.ts +7 -1
- package/lib/index.js +249 -147
- package/lib/requester/files.d.ts +2 -2
- package/lib/requester/runner.d.ts +2 -0
- package/lib/state/conversation.d.ts +7 -0
- package/lib/trigger/conditions.d.ts +3 -3
- package/lib/trigger/cooldown.d.ts +8 -4
- package/lib/trigger/engine.d.ts +1 -1
- package/lib/types.d.ts +5 -1
- package/lib/utils/session-text.d.ts +15 -10
- package/package.json +1 -1
package/lib/api/client.d.ts
CHANGED
|
@@ -18,7 +18,6 @@ export declare class DifyClient {
|
|
|
18
18
|
/** blocking JSON 请求(本插件只用 blocking 模式) */
|
|
19
19
|
private request;
|
|
20
20
|
getAppInfo(): Promise<DifyAppInfo>;
|
|
21
|
-
getParameters(): Promise<any>;
|
|
22
21
|
/** chat / agent-chat / advanced-chat(conversation_id 传空串表示新建会话) */
|
|
23
22
|
runChat(body: {
|
|
24
23
|
inputs: Record<string, any>;
|
package/lib/config.d.ts
CHANGED
|
@@ -120,8 +120,9 @@ export declare function resolveUploadConfig(config: Config): {
|
|
|
120
120
|
maxMb: number;
|
|
121
121
|
};
|
|
122
122
|
/**
|
|
123
|
-
* 引用触发消息配置(含 0.1.x 布尔开关迁移):schema
|
|
124
|
-
*
|
|
123
|
+
* 引用触发消息配置(含 0.1.x 布尔开关迁移):schema 会给旧配置填默认 quote 对象,
|
|
124
|
+
* 因此迁移判据是「旧布尔被配置层透传保留 且 quote 仍是默认形态(用户未在新表单改过)」——
|
|
125
|
+
* 此时以旧布尔为准;用户改过新表单(quote 非默认)则新值优先。
|
|
125
126
|
*/
|
|
126
127
|
export declare function resolveQuoteConfig(config: Config): {
|
|
127
128
|
enabled: boolean;
|
package/lib/history.d.ts
CHANGED
|
@@ -11,8 +11,12 @@ export interface HistoryEntry {
|
|
|
11
11
|
userId: string;
|
|
12
12
|
username: string;
|
|
13
13
|
messageId?: string;
|
|
14
|
-
/**
|
|
14
|
+
/** 原貌(含 @bot 渲染与图片占位):统一格式行(query/历史变量)用 */
|
|
15
15
|
content: string;
|
|
16
|
+
/** 剔除 @bot 的原貌:content 变量与精简行用 */
|
|
17
|
+
contentText: string;
|
|
18
|
+
/** 图片元素 URL(按元素序,与 content 中 [image:摘要] 序一致):窗口类触发的上传依据 */
|
|
19
|
+
imageUrls?: string[];
|
|
16
20
|
timestamp: number;
|
|
17
21
|
quote?: QuotedMessage;
|
|
18
22
|
}
|
|
@@ -24,6 +28,8 @@ export interface HistoryEntry {
|
|
|
24
28
|
export declare function formatHistoryEntry(entry: HistoryEntry): string;
|
|
25
29
|
/** 多条消息逐行拼接 */
|
|
26
30
|
export declare function formatHistoryEntries(entries: HistoryEntry[]): string;
|
|
31
|
+
/** 精简行:只记消息 ID 与剔除 @bot 的原貌内容,不带时间与作者前缀——窗口触发的 content 变量用 */
|
|
32
|
+
export declare function formatHistoryBrief(entries: HistoryEntry[]): string;
|
|
27
33
|
/** 从会话消息构造记录条目(历史缓冲 / 消息池共用);assistant 行标注为机器人自己的发言 */
|
|
28
34
|
export declare function buildHistoryEntry(session: Session, role?: 'user' | 'assistant'): HistoryEntry | null;
|
|
29
35
|
/**
|
package/lib/index.js
CHANGED
|
@@ -165,7 +165,8 @@ var uploadSchema = import_koishi.Schema.union([
|
|
|
165
165
|
mode: import_koishi.Schema.const("on").required(),
|
|
166
166
|
maxCount: import_koishi.Schema.natural().default(0).description("数量上限,0 不限"),
|
|
167
167
|
maxSizeMb: import_koishi.Schema.natural().default(0).description("单文件大小上限 MB,0 不限")
|
|
168
|
-
}).default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("开启")
|
|
168
|
+
}).default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("开启"),
|
|
169
|
+
import_koishi.Schema.boolean().description("旧版开关")
|
|
169
170
|
]);
|
|
170
171
|
var Config = import_koishi.Schema.intersect([
|
|
171
172
|
import_koishi.Schema.object({
|
|
@@ -316,14 +317,14 @@ function toVariable(entry) {
|
|
|
316
317
|
}
|
|
317
318
|
if (entry.source === "meta") {
|
|
318
319
|
if (entry.key === "none") return null;
|
|
319
|
-
return { field, source: { type: "meta", key: entry.key
|
|
320
|
+
return { field, source: { type: "meta", key: entry.key } };
|
|
320
321
|
}
|
|
321
322
|
if (entry.source === "history") {
|
|
322
323
|
const limit = Number(entry.limit);
|
|
323
324
|
return { field, source: { type: "history", ...limit > 0 ? { limit } : {} } };
|
|
324
325
|
}
|
|
325
326
|
if (entry.key === "none" || entry.key === "history") return null;
|
|
326
|
-
return { field, source: { type: "session", key: entry.key
|
|
327
|
+
return { field, source: { type: "session", key: entry.key } };
|
|
327
328
|
}
|
|
328
329
|
__name(toVariable, "toVariable");
|
|
329
330
|
function resolveUploadConfig(config) {
|
|
@@ -333,34 +334,29 @@ function resolveUploadConfig(config) {
|
|
|
333
334
|
if (config.uploadFiles?.mode === "off") return { enabled: false, maxCount: 0, maxMb: 0 };
|
|
334
335
|
return {
|
|
335
336
|
enabled: true,
|
|
336
|
-
maxCount:
|
|
337
|
-
maxMb:
|
|
337
|
+
maxCount: Number(config.uploadFiles?.maxCount) || 0,
|
|
338
|
+
maxMb: Number(config.uploadFiles?.maxSizeMb) || 0
|
|
338
339
|
};
|
|
339
340
|
}
|
|
340
341
|
__name(resolveUploadConfig, "resolveUploadConfig");
|
|
341
342
|
function resolveQuoteConfig(config) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
const quote = config.quote;
|
|
344
|
+
const isDefaultQuote = !quote || quote.mode === "on" && !(Number(quote.minElapsedSeconds) > 0);
|
|
345
|
+
if (typeof config.quoteTrigger === "boolean" && isDefaultQuote) {
|
|
346
|
+
return { enabled: config.quoteTrigger, minElapsedSeconds: 0 };
|
|
345
347
|
}
|
|
346
|
-
if (
|
|
347
|
-
return { enabled: true, minElapsedSeconds: 0 };
|
|
348
|
+
if (quote?.mode === "off") return { enabled: false, minElapsedSeconds: 0 };
|
|
349
|
+
return { enabled: true, minElapsedSeconds: Number(quote?.minElapsedSeconds) || 0 };
|
|
348
350
|
}
|
|
349
351
|
__name(resolveQuoteConfig, "resolveQuoteConfig");
|
|
350
352
|
function toCooldown(entry) {
|
|
351
353
|
if (entry?.mode === "fixed") {
|
|
352
|
-
|
|
353
|
-
return seconds > 0 ? { mode: "fixed", ms: seconds * 1e3 } : null;
|
|
354
|
+
return { mode: "fixed", ms: Number(entry.seconds) * 1e3 };
|
|
354
355
|
}
|
|
355
356
|
if (entry?.mode === "sliding") {
|
|
356
357
|
const minSeconds = Number(entry.minSeconds);
|
|
357
358
|
const maxSeconds = Number(entry.maxSeconds);
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
mode: "sliding",
|
|
361
|
-
minMs: minSeconds * 1e3,
|
|
362
|
-
maxMs: Math.max(minSeconds, maxSeconds) * 1e3
|
|
363
|
-
};
|
|
359
|
+
return { mode: "sliding", minMs: minSeconds * 1e3, maxMs: Math.max(minSeconds, maxSeconds) * 1e3 };
|
|
364
360
|
}
|
|
365
361
|
return null;
|
|
366
362
|
}
|
|
@@ -371,12 +367,16 @@ function normalizeConfig(config) {
|
|
|
371
367
|
if (app.name?.trim() && !apps.has(app.name)) apps.set(app.name, app);
|
|
372
368
|
}
|
|
373
369
|
const rules = [];
|
|
370
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
374
371
|
for (const entry of config.rules ?? []) {
|
|
375
372
|
if (!entry.name?.trim() || entry.enabled === false) continue;
|
|
373
|
+
const name2 = entry.name.trim();
|
|
374
|
+
if (seenNames.has(name2)) continue;
|
|
375
|
+
seenNames.add(name2);
|
|
376
376
|
const conditions = (entry.conditions ?? []).filter((condition) => condition != null).map(toCondition).filter((condition) => condition != null);
|
|
377
377
|
const variables = (entry.variables ?? []).filter((variable) => variable != null).map(toVariable).filter((variable) => variable != null);
|
|
378
378
|
rules.push({
|
|
379
|
-
name:
|
|
379
|
+
name: name2,
|
|
380
380
|
enabled: true,
|
|
381
381
|
priority: Number(entry.priority) || 0,
|
|
382
382
|
app: entry.app?.trim() ?? "",
|
|
@@ -449,9 +449,6 @@ var DifyClient = class {
|
|
|
449
449
|
getAppInfo() {
|
|
450
450
|
return this.request("GET", "/info");
|
|
451
451
|
}
|
|
452
|
-
getParameters() {
|
|
453
|
-
return this.request("GET", "/parameters");
|
|
454
|
-
}
|
|
455
452
|
/** chat / agent-chat / advanced-chat(conversation_id 传空串表示新建会话) */
|
|
456
453
|
runChat(body) {
|
|
457
454
|
return this.request("POST", "/chat-messages", body);
|
|
@@ -500,6 +497,7 @@ __name(createDifyClient, "createDifyClient");
|
|
|
500
497
|
// src/utils/session-text.ts
|
|
501
498
|
var import_crypto = require("crypto");
|
|
502
499
|
var import_koishi2 = require("koishi");
|
|
500
|
+
var IMAGE_HASH_LENGTH = 8;
|
|
503
501
|
function parseTextSafe(content) {
|
|
504
502
|
if (!content) return "";
|
|
505
503
|
try {
|
|
@@ -509,10 +507,9 @@ function parseTextSafe(content) {
|
|
|
509
507
|
}
|
|
510
508
|
}
|
|
511
509
|
__name(parseTextSafe, "parseTextSafe");
|
|
512
|
-
function extractText(session
|
|
510
|
+
function extractText(session) {
|
|
513
511
|
if (session.elements?.length) {
|
|
514
|
-
|
|
515
|
-
return import_koishi2.h.select(elements, "text").join("").trim();
|
|
512
|
+
return import_koishi2.h.select(session.elements, "text").join("").trim();
|
|
516
513
|
}
|
|
517
514
|
return parseTextSafe(session.content);
|
|
518
515
|
}
|
|
@@ -520,7 +517,7 @@ __name(extractText, "extractText");
|
|
|
520
517
|
var IMAGE_PLACEHOLDER = "[图片]";
|
|
521
518
|
function imagePlaceholder(url) {
|
|
522
519
|
if (!url) return IMAGE_PLACEHOLDER;
|
|
523
|
-
return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0,
|
|
520
|
+
return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0, IMAGE_HASH_LENGTH)}]`;
|
|
524
521
|
}
|
|
525
522
|
__name(imagePlaceholder, "imagePlaceholder");
|
|
526
523
|
function annotateDroppedPlaceholders(text, droppedUrls) {
|
|
@@ -532,19 +529,25 @@ function annotateDroppedPlaceholders(text, droppedUrls) {
|
|
|
532
529
|
return result;
|
|
533
530
|
}
|
|
534
531
|
__name(annotateDroppedPlaceholders, "annotateDroppedPlaceholders");
|
|
535
|
-
function
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
532
|
+
function elementsOf(session) {
|
|
533
|
+
if (session.elements?.length) return session.elements;
|
|
534
|
+
try {
|
|
535
|
+
return import_koishi2.h.parse(session.content ?? "");
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
__name(elementsOf, "elementsOf");
|
|
541
|
+
function extractTextWithPlaceholders(session, options = {}) {
|
|
543
542
|
const parts = [];
|
|
544
|
-
for (const element of
|
|
543
|
+
for (const element of elementsOf(session)) {
|
|
545
544
|
if (element.type === "text") {
|
|
546
545
|
const text = element.attrs?.["content"] ?? "";
|
|
547
546
|
if (text) parts.push(text);
|
|
547
|
+
} else if (element.type === "at") {
|
|
548
|
+
if (options.excludeBotAt && element.attrs?.["id"] === session.selfId) continue;
|
|
549
|
+
const name2 = element.attrs?.["name"] ?? element.attrs?.["id"];
|
|
550
|
+
if (name2) parts.push(`@${name2}`);
|
|
548
551
|
} else if (element.type === "img" || element.type === "image") {
|
|
549
552
|
parts.push(imagePlaceholder(element.attrs?.["url"] ?? element.attrs?.["src"]));
|
|
550
553
|
}
|
|
@@ -552,6 +555,16 @@ function extractTextWithPlaceholders(session) {
|
|
|
552
555
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
553
556
|
}
|
|
554
557
|
__name(extractTextWithPlaceholders, "extractTextWithPlaceholders");
|
|
558
|
+
function extractImageUrls(session) {
|
|
559
|
+
const urls = [];
|
|
560
|
+
for (const element of elementsOf(session)) {
|
|
561
|
+
if (element.type !== "img" && element.type !== "image") continue;
|
|
562
|
+
const source = element.attrs?.["url"] ?? element.attrs?.["src"];
|
|
563
|
+
if (typeof source === "string" && source) urls.push(source);
|
|
564
|
+
}
|
|
565
|
+
return urls;
|
|
566
|
+
}
|
|
567
|
+
__name(extractImageUrls, "extractImageUrls");
|
|
555
568
|
|
|
556
569
|
// src/history.ts
|
|
557
570
|
var HISTORY_CAPACITY_MULTIPLIER = 2;
|
|
@@ -572,6 +585,10 @@ function formatHistoryEntries(entries) {
|
|
|
572
585
|
return entries.map(formatHistoryEntry).join("\n");
|
|
573
586
|
}
|
|
574
587
|
__name(formatHistoryEntries, "formatHistoryEntries");
|
|
588
|
+
function formatHistoryBrief(entries) {
|
|
589
|
+
return entries.map((entry) => entry.messageId ? `[msg:${entry.messageId}] ${entry.contentText}` : entry.contentText).join("\n");
|
|
590
|
+
}
|
|
591
|
+
__name(formatHistoryBrief, "formatHistoryBrief");
|
|
575
592
|
function buildQuote(session) {
|
|
576
593
|
const quote = session.quote;
|
|
577
594
|
if (!quote?.user?.id) return void 0;
|
|
@@ -586,12 +603,15 @@ __name(buildQuote, "buildQuote");
|
|
|
586
603
|
function buildHistoryEntry(session, role = "user") {
|
|
587
604
|
const content = extractTextWithPlaceholders(session);
|
|
588
605
|
if (!content) return null;
|
|
606
|
+
const imageUrls = extractImageUrls(session);
|
|
589
607
|
return {
|
|
590
608
|
role,
|
|
591
609
|
userId: role === "assistant" ? session.selfId : session.userId ?? "未知用户",
|
|
592
|
-
username: role === "assistant" ? "你自己的发言" : session.username || session.userId
|
|
610
|
+
username: role === "assistant" ? "你自己的发言" : session.username || session.userId,
|
|
593
611
|
messageId: session.messageId,
|
|
594
612
|
content,
|
|
613
|
+
contentText: extractTextWithPlaceholders(session, { excludeBotAt: true }),
|
|
614
|
+
...imageUrls.length ? { imageUrls } : {},
|
|
595
615
|
timestamp: session.timestamp ?? Date.now(),
|
|
596
616
|
quote: role === "user" ? buildQuote(session) : void 0
|
|
597
617
|
};
|
|
@@ -629,40 +649,49 @@ function hasAt(session, id) {
|
|
|
629
649
|
return session.elements?.some((element) => element.type === "at" && element.attrs?.["id"] === id) ?? false;
|
|
630
650
|
}
|
|
631
651
|
__name(hasAt, "hasAt");
|
|
652
|
+
var NON_TEXT_PREFIX = new RegExp(`^(?:@\\S+|\\[image:[0-9a-f]{${IMAGE_HASH_LENGTH}}\\]|\\[图片\\]|\\s)*`);
|
|
653
|
+
function stripPrefixKeyword(verbatim, keyword) {
|
|
654
|
+
const prefix = NON_TEXT_PREFIX.exec(verbatim)?.[0] ?? "";
|
|
655
|
+
const rest = verbatim.slice(prefix.length);
|
|
656
|
+
if (!rest.startsWith(keyword)) return null;
|
|
657
|
+
return (prefix + rest.slice(keyword.length)).trim();
|
|
658
|
+
}
|
|
659
|
+
__name(stripPrefixKeyword, "stripPrefixKeyword");
|
|
632
660
|
function evaluateCondition(condition, session) {
|
|
633
661
|
const text = extractText(session);
|
|
662
|
+
const verbatim = extractTextWithPlaceholders(session, { excludeBotAt: true });
|
|
634
663
|
switch (condition.type) {
|
|
635
664
|
case "keyword-prefix": {
|
|
636
665
|
const keyword = condition.keywords.find((k) => k && text.startsWith(k));
|
|
637
666
|
if (!keyword) return null;
|
|
638
|
-
const rest = text.slice(keyword.length).trim();
|
|
639
|
-
return { type: "keyword-prefix",
|
|
667
|
+
const rest = verbatim.startsWith(keyword) ? verbatim.slice(keyword.length).trim() : stripPrefixKeyword(verbatim, keyword) ?? text.slice(keyword.length).trim();
|
|
668
|
+
return { type: "keyword-prefix", contentText: rest || verbatim };
|
|
640
669
|
}
|
|
641
670
|
case "keyword-contains": {
|
|
642
671
|
const keyword = condition.keywords.find((k) => k && text.includes(k));
|
|
643
672
|
if (!keyword) return null;
|
|
644
|
-
return { type: "keyword-contains",
|
|
673
|
+
return { type: "keyword-contains", contentText: verbatim };
|
|
645
674
|
}
|
|
646
675
|
case "at-bot": {
|
|
647
676
|
const botId = session.bot?.userId;
|
|
648
677
|
if (!botId) return null;
|
|
649
678
|
if (!session.stripped?.atSelf && !hasAt(session, botId)) return null;
|
|
650
|
-
return { type: "at-bot",
|
|
679
|
+
return { type: "at-bot", contentText: verbatim };
|
|
651
680
|
}
|
|
652
681
|
case "at-user": {
|
|
653
682
|
const isAtAnyUser = condition.userIds.some((id) => hasAt(session, id));
|
|
654
683
|
if (!isAtAnyUser) return null;
|
|
655
|
-
return { type: "at-user",
|
|
684
|
+
return { type: "at-user", contentText: verbatim };
|
|
656
685
|
}
|
|
657
686
|
case "quote-bot": {
|
|
658
687
|
const botId = session.bot?.userId;
|
|
659
688
|
if (!botId || session.quote?.user?.id !== botId) return null;
|
|
660
|
-
return { type: "quote-bot",
|
|
689
|
+
return { type: "quote-bot", contentText: verbatim };
|
|
661
690
|
}
|
|
662
691
|
case "quote-user": {
|
|
663
692
|
const author = session.quote?.user?.id;
|
|
664
693
|
if (!author || !condition.userIds.includes(author)) return null;
|
|
665
|
-
return { type: "quote-user",
|
|
694
|
+
return { type: "quote-user", contentText: verbatim };
|
|
666
695
|
}
|
|
667
696
|
default:
|
|
668
697
|
return null;
|
|
@@ -801,13 +830,7 @@ var RuleScheduler = class {
|
|
|
801
830
|
}
|
|
802
831
|
entries = [];
|
|
803
832
|
sync(rules) {
|
|
804
|
-
|
|
805
|
-
const nextEntries = [];
|
|
806
|
-
for (const rule of rules) {
|
|
807
|
-
const nextRunAt = previous.get(rule.name) ?? this.computeNextRun(rule);
|
|
808
|
-
if (nextRunAt != null) nextEntries.push({ rule, nextRunAt });
|
|
809
|
-
}
|
|
810
|
-
this.entries = nextEntries;
|
|
833
|
+
this.entries = rules.map((rule) => ({ rule, nextRunAt: this.computeNextRun(rule) })).filter((entry) => entry.nextRunAt != null);
|
|
811
834
|
}
|
|
812
835
|
tick() {
|
|
813
836
|
const now = Date.now();
|
|
@@ -857,8 +880,9 @@ var RuleScheduler = class {
|
|
|
857
880
|
|
|
858
881
|
// src/trigger/cooldown.ts
|
|
859
882
|
var BUFFER_CAPACITY = 1024;
|
|
883
|
+
var DENSITY_LANE_PREFIX = "density:";
|
|
860
884
|
function densityLane(index) {
|
|
861
|
-
return
|
|
885
|
+
return `${DENSITY_LANE_PREFIX}${index}`;
|
|
862
886
|
}
|
|
863
887
|
__name(densityLane, "densityLane");
|
|
864
888
|
function laneKeyOf(ruleName, lane) {
|
|
@@ -889,30 +913,33 @@ var TriggerCooldown = class {
|
|
|
889
913
|
});
|
|
890
914
|
}
|
|
891
915
|
isCooling(channelKey, ruleName, lane, now) {
|
|
892
|
-
const state = this.
|
|
916
|
+
const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
|
|
893
917
|
return state != null && now < state.deadline;
|
|
894
918
|
}
|
|
895
|
-
/**
|
|
896
|
-
|
|
897
|
-
if (this.setting?.mode !== "sliding") return;
|
|
898
|
-
const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
|
|
899
|
-
if (!state) return;
|
|
919
|
+
/** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
|
|
920
|
+
slide(state, setting, now) {
|
|
900
921
|
state.deadline = Math.min(
|
|
901
|
-
Math.max(state.deadline, now +
|
|
902
|
-
state.fireAt +
|
|
922
|
+
Math.max(state.deadline, now + setting.minMs),
|
|
923
|
+
state.fireAt + setting.maxMs
|
|
903
924
|
);
|
|
904
925
|
}
|
|
905
|
-
/**
|
|
926
|
+
/** 滑动窗口顺延:已到期的通道不复活 */
|
|
927
|
+
extend(channelKey, ruleName, lane, now) {
|
|
928
|
+
const setting = this.setting;
|
|
929
|
+
if (setting?.mode !== "sliding") return;
|
|
930
|
+
const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
|
|
931
|
+
if (!state || now >= state.deadline) return;
|
|
932
|
+
this.slide(state, setting, now);
|
|
933
|
+
}
|
|
934
|
+
/** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
|
|
906
935
|
extendDensityLanes(channelKey, now) {
|
|
907
|
-
|
|
936
|
+
const setting = this.setting;
|
|
937
|
+
if (setting?.mode !== "sliding") return;
|
|
908
938
|
const lanes = this.channels.get(channelKey);
|
|
909
939
|
if (!lanes) return;
|
|
910
940
|
for (const [key, state] of lanes) {
|
|
911
|
-
if (!key.includes("\ndensity:")) continue;
|
|
912
|
-
state
|
|
913
|
-
Math.max(state.deadline, now + this.setting.minMs),
|
|
914
|
-
state.fireAt + this.setting.maxMs
|
|
915
|
-
);
|
|
941
|
+
if (!key.includes("\ndensity:") || now >= state.deadline) continue;
|
|
942
|
+
this.slide(state, setting, now);
|
|
916
943
|
}
|
|
917
944
|
}
|
|
918
945
|
/** 冷却期内缓存一条命中消息(仅消息通道) */
|
|
@@ -924,22 +951,24 @@ var TriggerCooldown = class {
|
|
|
924
951
|
state.buffer.splice(0, state.buffer.length - BUFFER_CAPACITY);
|
|
925
952
|
}
|
|
926
953
|
}
|
|
927
|
-
/**
|
|
928
|
-
collectExpired(now) {
|
|
954
|
+
/** 到期扫描:取走到期通道(连缓冲一并交出),由调用方决定是否重新登记;限定频道时只扫该频道 */
|
|
955
|
+
collectExpired(now, channelKey) {
|
|
929
956
|
const expired = [];
|
|
930
|
-
|
|
931
|
-
|
|
957
|
+
const targets = channelKey ? [[channelKey, this.channels.get(channelKey)]] : this.channels;
|
|
958
|
+
for (const [key, lanes] of targets) {
|
|
959
|
+
if (!lanes) continue;
|
|
960
|
+
for (const [laneKey, state] of lanes) {
|
|
932
961
|
if (now < state.deadline) continue;
|
|
933
|
-
const separator =
|
|
934
|
-
lanes.delete(
|
|
962
|
+
const separator = laneKey.indexOf("\n");
|
|
963
|
+
lanes.delete(laneKey);
|
|
935
964
|
expired.push({
|
|
936
|
-
channelKey,
|
|
937
|
-
ruleName:
|
|
938
|
-
lane:
|
|
965
|
+
channelKey: key,
|
|
966
|
+
ruleName: laneKey.slice(0, separator),
|
|
967
|
+
lane: laneKey.slice(separator + 1),
|
|
939
968
|
buffer: state.buffer
|
|
940
969
|
});
|
|
941
970
|
}
|
|
942
|
-
if (!lanes.size) this.channels.delete(
|
|
971
|
+
if (!lanes.size) this.channels.delete(key);
|
|
943
972
|
}
|
|
944
973
|
return expired;
|
|
945
974
|
}
|
|
@@ -955,6 +984,29 @@ var TriggerCooldown = class {
|
|
|
955
984
|
|
|
956
985
|
// src/trigger/engine.ts
|
|
957
986
|
var SCHEDULER_TICK_MS = 1e3;
|
|
987
|
+
function collectImageUrls(entries) {
|
|
988
|
+
return entries.flatMap((entry) => entry?.role === "user" ? entry.imageUrls ?? [] : []);
|
|
989
|
+
}
|
|
990
|
+
__name(collectImageUrls, "collectImageUrls");
|
|
991
|
+
function sessionIdentityOf(session) {
|
|
992
|
+
return {
|
|
993
|
+
platform: session.platform,
|
|
994
|
+
selfId: session.selfId,
|
|
995
|
+
channelId: session.channelId,
|
|
996
|
+
guildId: session.guildId ?? session.channelId,
|
|
997
|
+
userId: session.userId
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
__name(sessionIdentityOf, "sessionIdentityOf");
|
|
1001
|
+
function poolIdentityOf(channel) {
|
|
1002
|
+
return {
|
|
1003
|
+
platform: channel.platform,
|
|
1004
|
+
selfId: channel.selfId,
|
|
1005
|
+
channelId: channel.channelId,
|
|
1006
|
+
guildId: channel.guildId
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
__name(poolIdentityOf, "poolIdentityOf");
|
|
958
1010
|
var TriggerEngine = class {
|
|
959
1011
|
constructor(ctx, runner, history, rules, cooldown) {
|
|
960
1012
|
this.ctx = ctx;
|
|
@@ -991,6 +1043,7 @@ var TriggerEngine = class {
|
|
|
991
1043
|
if (this.ctx.bots[session.uid]) return next();
|
|
992
1044
|
if (!session.channelId) return next();
|
|
993
1045
|
const channelKey = channelKeyOf(session.platform, session.channelId);
|
|
1046
|
+
this.tickCooldown(channelKey);
|
|
994
1047
|
const entry = buildHistoryEntry(session);
|
|
995
1048
|
if (entry) {
|
|
996
1049
|
this.history.recordEntry(channelKey, entry);
|
|
@@ -1006,13 +1059,14 @@ var TriggerEngine = class {
|
|
|
1006
1059
|
}
|
|
1007
1060
|
}
|
|
1008
1061
|
return next(async (nextMiddleware) => {
|
|
1062
|
+
this.tickCooldown(channelKey);
|
|
1009
1063
|
const matched = this.matchRule(session);
|
|
1010
1064
|
if (!matched) return nextMiddleware();
|
|
1011
1065
|
if (this.cooldown.isCooling(channelKey, matched.rule.name, "message", Date.now())) {
|
|
1012
1066
|
this.cooldown.bufferMessage(channelKey, matched.rule.name, {
|
|
1013
1067
|
session,
|
|
1014
1068
|
entry,
|
|
1015
|
-
contentText: matched.condition.
|
|
1069
|
+
contentText: matched.condition.contentText
|
|
1016
1070
|
});
|
|
1017
1071
|
this.cooldown.extend(channelKey, matched.rule.name, "message", Date.now());
|
|
1018
1072
|
return;
|
|
@@ -1043,32 +1097,24 @@ var TriggerEngine = class {
|
|
|
1043
1097
|
this.beginCooldown(channelKeyOf(session.platform, session.channelId), rule.name, "message");
|
|
1044
1098
|
this.run({
|
|
1045
1099
|
session,
|
|
1046
|
-
|
|
1047
|
-
selfId: session.selfId,
|
|
1048
|
-
channelId: session.channelId,
|
|
1049
|
-
guildId: session.guildId ?? session.channelId,
|
|
1050
|
-
userId: session.userId,
|
|
1100
|
+
...sessionIdentityOf(session),
|
|
1051
1101
|
triggerType: condition.type,
|
|
1052
1102
|
rule,
|
|
1053
1103
|
// query 为该条消息的统一格式单行;纯 @ / 纯引用等无文本消息 entry 为空,由请求器占位兜底
|
|
1054
1104
|
queryText: entry ? formatHistoryEntry(entry) : "",
|
|
1055
|
-
contentText: condition.
|
|
1105
|
+
contentText: condition.contentText
|
|
1056
1106
|
});
|
|
1057
1107
|
}
|
|
1058
1108
|
fireDensity(fire, session) {
|
|
1059
1109
|
this.beginCooldown(channelKeyOf(session.platform, session.channelId), fire.rule.name, densityLane(fire.conditionIndex));
|
|
1060
|
-
const windowText = formatHistoryEntries(fire.entries);
|
|
1061
1110
|
this.run({
|
|
1062
1111
|
session,
|
|
1063
|
-
|
|
1064
|
-
selfId: session.selfId,
|
|
1065
|
-
channelId: session.channelId,
|
|
1066
|
-
guildId: session.guildId ?? session.channelId,
|
|
1067
|
-
userId: session.userId,
|
|
1112
|
+
...sessionIdentityOf(session),
|
|
1068
1113
|
triggerType: "density",
|
|
1069
1114
|
rule: fire.rule,
|
|
1070
|
-
queryText:
|
|
1071
|
-
contentText:
|
|
1115
|
+
queryText: formatHistoryEntries(fire.entries),
|
|
1116
|
+
contentText: formatHistoryBrief(fire.entries),
|
|
1117
|
+
imageUrls: collectImageUrls(fire.entries)
|
|
1072
1118
|
});
|
|
1073
1119
|
}
|
|
1074
1120
|
/** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
|
|
@@ -1079,30 +1125,37 @@ var TriggerEngine = class {
|
|
|
1079
1125
|
this.beginCooldown(channelKeyOf(first.session.platform, first.session.channelId), rule.name, "message");
|
|
1080
1126
|
this.run({
|
|
1081
1127
|
session: first.session,
|
|
1082
|
-
|
|
1083
|
-
selfId: first.session.selfId,
|
|
1084
|
-
channelId: first.session.channelId,
|
|
1085
|
-
guildId: first.session.guildId ?? first.session.channelId,
|
|
1086
|
-
userId: first.session.userId,
|
|
1128
|
+
...sessionIdentityOf(first.session),
|
|
1087
1129
|
triggerType: "merged",
|
|
1088
1130
|
rule,
|
|
1089
1131
|
queryText,
|
|
1090
|
-
contentText
|
|
1132
|
+
contentText,
|
|
1133
|
+
// 补齐缓冲内非首条消息的图片(首条由 session 元素提取,重复由上传入口去重)
|
|
1134
|
+
imageUrls: collectImageUrls(buffer.map((item) => item.entry))
|
|
1091
1135
|
});
|
|
1092
1136
|
}
|
|
1093
1137
|
/** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
|
|
1094
|
-
fireDensityExpiry(rule, conditionIndex, channel) {
|
|
1138
|
+
fireDensityExpiry(rule, conditionIndex, channel, now) {
|
|
1095
1139
|
this.beginCooldown(channelKeyOf(channel.platform, channel.channelId), rule.name, densityLane(conditionIndex));
|
|
1096
|
-
const
|
|
1140
|
+
const condition = rule.conditions[conditionIndex];
|
|
1141
|
+
const windowMs = condition.type === "density" ? condition.densityWindowSeconds * 1e3 : 0;
|
|
1142
|
+
let userId;
|
|
1143
|
+
for (let i = channel.entries.length - 1; i >= 0; i--) {
|
|
1144
|
+
const item = channel.entries[i];
|
|
1145
|
+
if (item.role === "user" && now - item.timestamp <= windowMs) {
|
|
1146
|
+
userId = item.userId;
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1097
1150
|
this.run({
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
channelId: channel.channelId,
|
|
1101
|
-
guildId: channel.guildId,
|
|
1151
|
+
...poolIdentityOf(channel),
|
|
1152
|
+
userId,
|
|
1102
1153
|
triggerType: "density",
|
|
1103
1154
|
rule,
|
|
1104
|
-
queryText:
|
|
1105
|
-
contentText:
|
|
1155
|
+
queryText: formatHistoryEntries(channel.entries),
|
|
1156
|
+
contentText: formatHistoryBrief(channel.entries),
|
|
1157
|
+
imageUrls: collectImageUrls(channel.entries),
|
|
1158
|
+
isDirect: channel.isDirect
|
|
1106
1159
|
});
|
|
1107
1160
|
}
|
|
1108
1161
|
/**
|
|
@@ -1111,24 +1164,22 @@ var TriggerEngine = class {
|
|
|
1111
1164
|
*/
|
|
1112
1165
|
fireSchedule(rule) {
|
|
1113
1166
|
for (const channel of this.pool.takeSchedule(rule)) {
|
|
1114
|
-
const windowText = formatHistoryEntries(channel.entries);
|
|
1115
1167
|
this.run({
|
|
1116
|
-
|
|
1117
|
-
selfId: channel.selfId,
|
|
1118
|
-
channelId: channel.channelId,
|
|
1119
|
-
guildId: channel.guildId,
|
|
1168
|
+
...poolIdentityOf(channel),
|
|
1120
1169
|
triggerType: "schedule",
|
|
1121
1170
|
rule,
|
|
1122
|
-
queryText:
|
|
1123
|
-
contentText:
|
|
1171
|
+
queryText: formatHistoryEntries(channel.entries),
|
|
1172
|
+
contentText: formatHistoryBrief(channel.entries),
|
|
1173
|
+
imageUrls: collectImageUrls(channel.entries),
|
|
1174
|
+
isDirect: channel.isDirect
|
|
1124
1175
|
});
|
|
1125
1176
|
}
|
|
1126
1177
|
}
|
|
1127
|
-
/**
|
|
1128
|
-
tickCooldown() {
|
|
1178
|
+
/** 扫描冷却到期(带频道只扫该频道,消息路径用;每秒 interval 全量扫描):消息通道合并触发,密度通道复查池 */
|
|
1179
|
+
tickCooldown(channelKey) {
|
|
1129
1180
|
if (!this.cooldown.enabled) return;
|
|
1130
1181
|
const now = Date.now();
|
|
1131
|
-
for (const expired of this.cooldown.collectExpired(now)) {
|
|
1182
|
+
for (const expired of this.cooldown.collectExpired(now, channelKey)) {
|
|
1132
1183
|
this.onCooldownExpired(expired, now);
|
|
1133
1184
|
}
|
|
1134
1185
|
}
|
|
@@ -1139,11 +1190,11 @@ var TriggerEngine = class {
|
|
|
1139
1190
|
if (expired.buffer.length) this.fireMerged(rule, expired.buffer);
|
|
1140
1191
|
return;
|
|
1141
1192
|
}
|
|
1142
|
-
const index = Number(expired.lane.slice(
|
|
1193
|
+
const index = Number(expired.lane.slice(DENSITY_LANE_PREFIX.length));
|
|
1143
1194
|
const condition = rule.conditions[index];
|
|
1144
1195
|
if (condition?.type !== "density") return;
|
|
1145
1196
|
const channel = this.pool.takeDensityChannel(rule, condition, expired.channelKey, now);
|
|
1146
|
-
if (channel) this.fireDensityExpiry(rule, index, channel);
|
|
1197
|
+
if (channel) this.fireDensityExpiry(rule, index, channel, now);
|
|
1147
1198
|
}
|
|
1148
1199
|
};
|
|
1149
1200
|
|
|
@@ -1235,11 +1286,26 @@ function extractFileCandidates(session) {
|
|
|
1235
1286
|
return candidates;
|
|
1236
1287
|
}
|
|
1237
1288
|
__name(extractFileCandidates, "extractFileCandidates");
|
|
1289
|
+
function percentDecodeBytes(input) {
|
|
1290
|
+
const bytes = [];
|
|
1291
|
+
for (let i = 0; i < input.length; i++) {
|
|
1292
|
+
const char = input[i];
|
|
1293
|
+
if (char === "%" && /^[0-9a-fA-F]{2}$/.test(input.slice(i + 1, i + 3))) {
|
|
1294
|
+
bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
|
|
1295
|
+
i += 2;
|
|
1296
|
+
} else {
|
|
1297
|
+
bytes.push(char.charCodeAt(0) & 255);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return Buffer.from(bytes);
|
|
1301
|
+
}
|
|
1302
|
+
__name(percentDecodeBytes, "percentDecodeBytes");
|
|
1238
1303
|
function parseDataUrl(source) {
|
|
1239
|
-
const match = source.match(/^data:([
|
|
1304
|
+
const match = source.match(/^data:([^;,]*)((?:;[^;,]*)*)?,(.+)$/);
|
|
1240
1305
|
if (!match) return null;
|
|
1241
|
-
const mimeType = match[1];
|
|
1242
|
-
const
|
|
1306
|
+
const mimeType = match[1] || "application/octet-stream";
|
|
1307
|
+
const isBase64 = /(?:^|;)base64/.test(match[2] ?? "");
|
|
1308
|
+
const buffer = isBase64 ? Buffer.from(match[3], "base64") : percentDecodeBytes(match[3]);
|
|
1243
1309
|
return { buffer, fileName: `file.${guessExtension(mimeType)}`, mimeType };
|
|
1244
1310
|
}
|
|
1245
1311
|
__name(parseDataUrl, "parseDataUrl");
|
|
@@ -1313,19 +1379,28 @@ async function uploadOne(ctx, client, candidate, difyUser, limit) {
|
|
|
1313
1379
|
}
|
|
1314
1380
|
}
|
|
1315
1381
|
__name(uploadOne, "uploadOne");
|
|
1316
|
-
async function
|
|
1317
|
-
const
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1382
|
+
async function uploadCandidates(ctx, client, candidates, difyUser, limit) {
|
|
1383
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1384
|
+
const unique = candidates.filter((candidate) => {
|
|
1385
|
+
if (seen.has(candidate.source)) return false;
|
|
1386
|
+
seen.add(candidate.source);
|
|
1387
|
+
return true;
|
|
1388
|
+
});
|
|
1389
|
+
const limited = limit.maxCount > 0 ? unique.slice(0, limit.maxCount) : unique;
|
|
1390
|
+
const dropped = [];
|
|
1391
|
+
const markDropped = /* @__PURE__ */ __name((source) => {
|
|
1392
|
+
if (!dropped.includes(source)) dropped.push(source);
|
|
1393
|
+
}, "markDropped");
|
|
1394
|
+
unique.slice(limited.length).forEach((candidate) => markDropped(candidate.source));
|
|
1320
1395
|
const files = [];
|
|
1321
1396
|
for (const candidate of limited) {
|
|
1322
1397
|
const uploaded = await uploadOne(ctx, client, candidate, difyUser, limit);
|
|
1323
1398
|
if (uploaded) files.push(uploaded);
|
|
1324
|
-
else
|
|
1399
|
+
else markDropped(candidate.source);
|
|
1325
1400
|
}
|
|
1326
1401
|
return { files, dropped };
|
|
1327
1402
|
}
|
|
1328
|
-
__name(
|
|
1403
|
+
__name(uploadCandidates, "uploadCandidates");
|
|
1329
1404
|
|
|
1330
1405
|
// src/requester/variables.ts
|
|
1331
1406
|
function resolveSessionField(key, fireCtx, now) {
|
|
@@ -1354,7 +1429,7 @@ function resolveSessionField(key, fireCtx, now) {
|
|
|
1354
1429
|
case "timestamp":
|
|
1355
1430
|
return now;
|
|
1356
1431
|
case "isDirect":
|
|
1357
|
-
return session?.isDirect ?? false;
|
|
1432
|
+
return session?.isDirect ?? fireCtx.isDirect ?? false;
|
|
1358
1433
|
default:
|
|
1359
1434
|
return "";
|
|
1360
1435
|
}
|
|
@@ -1436,7 +1511,7 @@ ${line}` : line;
|
|
|
1436
1511
|
__name(splitText, "splitText");
|
|
1437
1512
|
function renderReply(input) {
|
|
1438
1513
|
const messages = [];
|
|
1439
|
-
const segments = splitText(input.text, input.config.maxSegmentLength
|
|
1514
|
+
const segments = splitText(input.text, input.config.maxSegmentLength);
|
|
1440
1515
|
segments.forEach((segment, index) => {
|
|
1441
1516
|
const elements = [];
|
|
1442
1517
|
if (index === 0 && input.quoteMessageId) {
|
|
@@ -1492,6 +1567,7 @@ function extractTextOutputs(outputs) {
|
|
|
1492
1567
|
__name(extractTextOutputs, "extractTextOutputs");
|
|
1493
1568
|
function parseWorkflowResponse(response) {
|
|
1494
1569
|
const data = response.data;
|
|
1570
|
+
if (!data) throw new DifyApiError("workflow 响应缺少 data", 200);
|
|
1495
1571
|
if (data.error) throw new DifyApiError(data.error, 200);
|
|
1496
1572
|
if (data.status && !SUCCESS_STATUSES.includes(data.status)) {
|
|
1497
1573
|
throw new DifyApiError(`workflow ${data.status}`, 200);
|
|
@@ -1534,11 +1610,13 @@ var RequestRunner = class {
|
|
|
1534
1610
|
uploadedFiles: []
|
|
1535
1611
|
};
|
|
1536
1612
|
const upload = resolveUploadConfig(this.config);
|
|
1537
|
-
if (upload.enabled
|
|
1538
|
-
const
|
|
1613
|
+
if (upload.enabled) {
|
|
1614
|
+
const sessionCandidates = fireCtx.session ? extractFileCandidates(fireCtx.session) : [];
|
|
1615
|
+
const urlCandidates = (fireCtx.imageUrls ?? []).filter((url) => !sessionCandidates.some((candidate) => candidate.source === url)).map((url) => ({ source: url, type: "image" }));
|
|
1616
|
+
const { files, dropped } = await uploadCandidates(
|
|
1539
1617
|
this.ctx,
|
|
1540
1618
|
client,
|
|
1541
|
-
|
|
1619
|
+
[...sessionCandidates, ...urlCandidates],
|
|
1542
1620
|
requestCtx.difyUser,
|
|
1543
1621
|
{ maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
|
|
1544
1622
|
);
|
|
@@ -1562,17 +1640,20 @@ var RequestRunner = class {
|
|
|
1562
1640
|
this.ctx.logger.warn("规则 %s 的请求未返回任何内容", rule.name);
|
|
1563
1641
|
return;
|
|
1564
1642
|
}
|
|
1565
|
-
const quote = resolveQuoteConfig(this.config);
|
|
1566
|
-
const elapsedSeconds = (Date.now() - startedAt) / 1e3;
|
|
1567
|
-
const shouldQuote = quote.enabled && elapsedSeconds >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
|
|
1568
1643
|
const messages = renderReply({
|
|
1569
1644
|
text: reply.text,
|
|
1570
1645
|
files: reply.files,
|
|
1571
|
-
quoteMessageId:
|
|
1646
|
+
quoteMessageId: this.shouldQuoteReply(fireCtx, startedAt) ? fireCtx.session?.messageId : void 0,
|
|
1572
1647
|
config: this.config
|
|
1573
1648
|
});
|
|
1574
1649
|
await this.deliver(fireCtx, messages);
|
|
1575
1650
|
}
|
|
1651
|
+
/** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
|
|
1652
|
+
shouldQuoteReply(fireCtx, startedAt) {
|
|
1653
|
+
const quote = resolveQuoteConfig(this.config);
|
|
1654
|
+
if (!quote.enabled) return false;
|
|
1655
|
+
return (Date.now() - startedAt) / 1e3 >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
|
|
1656
|
+
}
|
|
1576
1657
|
/** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
|
|
1577
1658
|
async dispatch(app, client, fireCtx, requestCtx) {
|
|
1578
1659
|
const base = {
|
|
@@ -1610,7 +1691,8 @@ var RequestRunner = class {
|
|
|
1610
1691
|
}
|
|
1611
1692
|
return;
|
|
1612
1693
|
}
|
|
1613
|
-
const
|
|
1694
|
+
const bots = Object.values(this.ctx.bots);
|
|
1695
|
+
const bot = bots.find((b) => b.platform === fireCtx.platform && b.selfId === fireCtx.selfId) ?? bots.find((b) => b.platform === fireCtx.platform);
|
|
1614
1696
|
if (!bot) {
|
|
1615
1697
|
this.ctx.logger.warn("平台 %s 上没有可用 bot,无法发送主动消息", fireCtx.platform);
|
|
1616
1698
|
return;
|
|
@@ -1632,7 +1714,7 @@ var ConversationStore = class {
|
|
|
1632
1714
|
}
|
|
1633
1715
|
/** 粒度归一:bindingKey 与 difyUser 共用,保证两者始终对齐 */
|
|
1634
1716
|
resolveScopeDimension(rule, fireCtx) {
|
|
1635
|
-
if (fireCtx
|
|
1717
|
+
if (this.isDirectOf(fireCtx) || rule.conversationScope === "per-user") return "user";
|
|
1636
1718
|
switch (rule.conversationScope) {
|
|
1637
1719
|
case "global":
|
|
1638
1720
|
return "global";
|
|
@@ -1642,13 +1724,17 @@ var ConversationStore = class {
|
|
|
1642
1724
|
return "guild";
|
|
1643
1725
|
}
|
|
1644
1726
|
}
|
|
1727
|
+
/** 私聊判定:有 session 读 session,无 session 的主动触发读池透传的标记 */
|
|
1728
|
+
isDirectOf(fireCtx) {
|
|
1729
|
+
return fireCtx.session?.isDirect ?? fireCtx.isDirect ?? false;
|
|
1730
|
+
}
|
|
1645
1731
|
/** bindingKey = 应用 ID + 粒度 + 平台身份 */
|
|
1646
1732
|
computeBindingKey(appId, rule, fireCtx) {
|
|
1647
1733
|
const base = `${appId}:${fireCtx.platform}:${fireCtx.selfId}`;
|
|
1648
1734
|
switch (this.resolveScopeDimension(rule, fireCtx)) {
|
|
1649
1735
|
// 群内按「群+用户」区分;私聊用 private 段占位
|
|
1650
1736
|
case "user":
|
|
1651
|
-
return fireCtx
|
|
1737
|
+
return this.isDirectOf(fireCtx) ? `${base}:private:${fireCtx.userId ?? fireCtx.channelId}` : `${base}:user:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
|
|
1652
1738
|
case "guild":
|
|
1653
1739
|
return `${base}:guild:${fireCtx.guildId ?? fireCtx.channelId}`;
|
|
1654
1740
|
case "channel":
|
|
@@ -1665,7 +1751,7 @@ var ConversationStore = class {
|
|
|
1665
1751
|
computeDifyUser(rule, fireCtx) {
|
|
1666
1752
|
switch (this.resolveScopeDimension(rule, fireCtx)) {
|
|
1667
1753
|
case "user":
|
|
1668
|
-
return fireCtx
|
|
1754
|
+
return this.isDirectOf(fireCtx) ? `koishi:${fireCtx.platform}:private:${fireCtx.userId ?? fireCtx.channelId}` : `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
|
|
1669
1755
|
case "guild":
|
|
1670
1756
|
return `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}`;
|
|
1671
1757
|
case "channel":
|
|
@@ -1705,16 +1791,25 @@ var ConversationStore = class {
|
|
|
1705
1791
|
/**
|
|
1706
1792
|
* 重置匹配的绑定:本地删行 + 尽力删除 Dify 侧会话(失败仅记录)。
|
|
1707
1793
|
* 表已无 guildKey 列,按频道匹配时全表读回后解析 bindingKey 过滤(行数量级很小)。
|
|
1794
|
+
* 覆盖 guild / user(群内)/ channel 段与私聊执行时的 private 段;
|
|
1795
|
+
* global 段跨频道,不随单频道重置。
|
|
1708
1796
|
*/
|
|
1709
1797
|
async resetWhere(filter) {
|
|
1710
1798
|
const rows = await this.ctx.database.get("dify_conversation", {});
|
|
1799
|
+
const splitKey = /* @__PURE__ */ __name((key) => {
|
|
1800
|
+
const separator = key.indexOf(":");
|
|
1801
|
+
return separator < 0 ? [key, ""] : [key.slice(0, separator), key.slice(separator + 1)];
|
|
1802
|
+
}, "splitKey");
|
|
1803
|
+
const [guildPlatform, guildId] = splitKey(filter.guildKey ?? "");
|
|
1804
|
+
const [channelPlatform, channelId] = splitKey(filter.channelKey ?? "");
|
|
1711
1805
|
const matched = rows.filter((row) => {
|
|
1712
1806
|
if (filter.bindingKey) return row.bindingKey === filter.bindingKey;
|
|
1713
1807
|
const { platform, scope, ids } = this.parseBindingKey(row.bindingKey);
|
|
1714
|
-
const [guildPlatform, guildId] = (filter.guildKey ?? "").split(":");
|
|
1715
1808
|
if (platform !== guildPlatform) return false;
|
|
1716
1809
|
if (scope === "guild") return ids[0] === guildId;
|
|
1717
1810
|
if (scope === "user") return ids.length === 2 ? ids[0] === guildId : false;
|
|
1811
|
+
if (scope === "channel") return platform === channelPlatform && ids[0] === channelId;
|
|
1812
|
+
if (scope === "private") return filter.isDirect === true && ids[0] === filter.userId;
|
|
1718
1813
|
return false;
|
|
1719
1814
|
});
|
|
1720
1815
|
let reset = 0;
|
|
@@ -1757,8 +1852,12 @@ __name(defineTables, "defineTables");
|
|
|
1757
1852
|
// src/commands/misc.ts
|
|
1758
1853
|
function registerMiscCommands(parent, conversationStore) {
|
|
1759
1854
|
parent.subcommand(".conv.reset", { authority: 3 }).action(async ({ session }) => {
|
|
1760
|
-
const
|
|
1761
|
-
|
|
1855
|
+
const reset = await conversationStore.resetWhere({
|
|
1856
|
+
guildKey: guildKeyOf(session.platform, session.guildId, session.channelId),
|
|
1857
|
+
channelKey: channelKeyOf(session.platform, session.channelId),
|
|
1858
|
+
isDirect: session.isDirect,
|
|
1859
|
+
userId: session.userId
|
|
1860
|
+
});
|
|
1762
1861
|
if (!reset) return session.text("dify-ai.commands.conv-empty");
|
|
1763
1862
|
return session.text("dify-ai.commands.conv-reset", [reset]);
|
|
1764
1863
|
});
|
|
@@ -1768,6 +1867,7 @@ __name(registerMiscCommands, "registerMiscCommands");
|
|
|
1768
1867
|
// src/index.ts
|
|
1769
1868
|
var name = "dify-ai";
|
|
1770
1869
|
var inject = { required: ["database"] };
|
|
1870
|
+
var PROBE_TIMEOUT_MS = 1e4;
|
|
1771
1871
|
function apply(ctx, config) {
|
|
1772
1872
|
ctx.i18n.define("zh-CN", zh_CN_default);
|
|
1773
1873
|
ctx.i18n.define("en-US", en_US_default);
|
|
@@ -1778,18 +1878,20 @@ function apply(ctx, config) {
|
|
|
1778
1878
|
const conversationStore = new ConversationStore(ctx, apps);
|
|
1779
1879
|
const runner = new RequestRunner(ctx, config, conversationStore, history, apps, appTypes);
|
|
1780
1880
|
const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
|
|
1781
|
-
ctx.on("ready",
|
|
1782
|
-
await probeAppTypes(ctx, config, apps, appTypes);
|
|
1881
|
+
ctx.on("ready", () => {
|
|
1783
1882
|
engine.start();
|
|
1883
|
+
void probeAppTypes(ctx, apps, appTypes);
|
|
1784
1884
|
});
|
|
1785
1885
|
const admin = ctx.command("dify", { authority: 3 });
|
|
1786
1886
|
registerMiscCommands(admin, conversationStore);
|
|
1787
1887
|
}
|
|
1788
1888
|
__name(apply, "apply");
|
|
1789
|
-
async function probeAppTypes(ctx,
|
|
1889
|
+
async function probeAppTypes(ctx, apps, appTypes) {
|
|
1890
|
+
const autoApps = [...apps.values()].filter((app) => app.appType === "auto");
|
|
1891
|
+
if (!autoApps.length) return;
|
|
1790
1892
|
await Promise.all(
|
|
1791
|
-
|
|
1792
|
-
const client = createDifyClient(ctx, app,
|
|
1893
|
+
autoApps.map(async (app) => {
|
|
1894
|
+
const client = createDifyClient(ctx, app, PROBE_TIMEOUT_MS);
|
|
1793
1895
|
if (!client) return;
|
|
1794
1896
|
try {
|
|
1795
1897
|
const info = await client.getAppInfo();
|
package/lib/requester/files.d.ts
CHANGED
|
@@ -27,6 +27,6 @@ export interface UploadResult {
|
|
|
27
27
|
files: DifyInputFileObject[];
|
|
28
28
|
dropped: string[];
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
31
|
-
export declare function
|
|
30
|
+
/** 上传一批候选(入口按 type:source 去重,再按数量上限截断、逐个上传) */
|
|
31
|
+
export declare function uploadCandidates(ctx: Context, client: DifyClient, candidates: FileCandidate[], difyUser: string, limit: UploadLimit): Promise<UploadResult>;
|
|
32
32
|
export {};
|
|
@@ -28,6 +28,8 @@ export declare class RequestRunner {
|
|
|
28
28
|
/** 应用类型:schema 显式指定优先,auto 时用探测结果(未探测到回退 chat) */
|
|
29
29
|
private appTypeOf;
|
|
30
30
|
run(fireCtx: FireContext): Promise<void>;
|
|
31
|
+
/** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
|
|
32
|
+
private shouldQuoteReply;
|
|
31
33
|
/** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
|
|
32
34
|
private dispatch;
|
|
33
35
|
private deliver;
|
|
@@ -15,6 +15,8 @@ export declare class ConversationStore {
|
|
|
15
15
|
apps: Map<string, AppConfigEntry>);
|
|
16
16
|
/** 粒度归一:bindingKey 与 difyUser 共用,保证两者始终对齐 */
|
|
17
17
|
private resolveScopeDimension;
|
|
18
|
+
/** 私聊判定:有 session 读 session,无 session 的主动触发读池透传的标记 */
|
|
19
|
+
private isDirectOf;
|
|
18
20
|
/** bindingKey = 应用 ID + 粒度 + 平台身份 */
|
|
19
21
|
computeBindingKey(appId: string, rule: DifyRule, fireCtx: FireContext): string;
|
|
20
22
|
/**
|
|
@@ -32,10 +34,15 @@ export declare class ConversationStore {
|
|
|
32
34
|
/**
|
|
33
35
|
* 重置匹配的绑定:本地删行 + 尽力删除 Dify 侧会话(失败仅记录)。
|
|
34
36
|
* 表已无 guildKey 列,按频道匹配时全表读回后解析 bindingKey 过滤(行数量级很小)。
|
|
37
|
+
* 覆盖 guild / user(群内)/ channel 段与私聊执行时的 private 段;
|
|
38
|
+
* global 段跨频道,不随单频道重置。
|
|
35
39
|
*/
|
|
36
40
|
resetWhere(filter: {
|
|
37
41
|
bindingKey?: string;
|
|
38
42
|
guildKey?: string;
|
|
43
|
+
channelKey?: string;
|
|
44
|
+
isDirect?: boolean;
|
|
45
|
+
userId?: string;
|
|
39
46
|
}): Promise<number>;
|
|
40
47
|
/** 按应用 ID(apps 配置的 appId 字段)反查应用 */
|
|
41
48
|
private findByAppId;
|
|
@@ -2,10 +2,10 @@ import { Session } from 'koishi';
|
|
|
2
2
|
import { MessageTriggerType, TriggerCondition, TriggerScope } from '../types';
|
|
3
3
|
export interface ConditionMatched {
|
|
4
4
|
type: MessageTriggerType;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/** content 变量的剥离文本,保留 @ 与图片占位(query 由引擎按统一消息格式另行构造) */
|
|
6
|
+
contentText: string;
|
|
7
7
|
}
|
|
8
|
-
/** 评估单个消息条件;返回 null
|
|
8
|
+
/** 评估单个消息条件;返回 null 表示未命中(匹配基于纯文本,@ 元素不参与判定) */
|
|
9
9
|
export declare function evaluateCondition(condition: TriggerCondition, session: Session): ConditionMatched | null;
|
|
10
10
|
/** 作用域过滤:留空的维度不做限制 */
|
|
11
11
|
export declare function matchScope(scope: TriggerScope, session: Session): boolean;
|
|
@@ -10,6 +10,8 @@ export interface PendingMessage {
|
|
|
10
10
|
}
|
|
11
11
|
/** 冷却通道:message = 规则内全部消息类条件共享;density:N = 第 N 个密度条件独立 */
|
|
12
12
|
export type CooldownLane = 'message' | `density:${number}`;
|
|
13
|
+
/** 密度通道名的下标前缀 */
|
|
14
|
+
export declare const DENSITY_LANE_PREFIX = "density:";
|
|
13
15
|
/** 密度条件的通道名 */
|
|
14
16
|
export declare function densityLane(index: number): CooldownLane;
|
|
15
17
|
/** 到期扫描交出的通道产物 */
|
|
@@ -34,13 +36,15 @@ export declare class TriggerCooldown {
|
|
|
34
36
|
/** 触发发起时登记冷却 */
|
|
35
37
|
begin(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
|
|
36
38
|
isCooling(channelKey: string, ruleName: string, lane: CooldownLane, now: number): boolean;
|
|
37
|
-
/**
|
|
39
|
+
/** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
|
|
40
|
+
private slide;
|
|
41
|
+
/** 滑动窗口顺延:已到期的通道不复活 */
|
|
38
42
|
extend(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
|
|
39
|
-
/**
|
|
43
|
+
/** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
|
|
40
44
|
extendDensityLanes(channelKey: string, now: number): void;
|
|
41
45
|
/** 冷却期内缓存一条命中消息(仅消息通道) */
|
|
42
46
|
bufferMessage(channelKey: string, ruleName: string, message: PendingMessage): void;
|
|
43
|
-
/**
|
|
44
|
-
collectExpired(now: number): ExpiredCooldown[];
|
|
47
|
+
/** 到期扫描:取走到期通道(连缓冲一并交出),由调用方决定是否重新登记;限定频道时只扫该频道 */
|
|
48
|
+
collectExpired(now: number, channelKey?: string): ExpiredCooldown[];
|
|
45
49
|
private lanesOf;
|
|
46
50
|
}
|
package/lib/trigger/engine.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare class TriggerEngine {
|
|
|
33
33
|
* 频道身份取自池内最近一条消息;无消息的频道自然跳过。定时不受冷却影响。
|
|
34
34
|
*/
|
|
35
35
|
private fireSchedule;
|
|
36
|
-
/**
|
|
36
|
+
/** 扫描冷却到期(带频道只扫该频道,消息路径用;每秒 interval 全量扫描):消息通道合并触发,密度通道复查池 */
|
|
37
37
|
private tickCooldown;
|
|
38
38
|
private onCooldownExpired;
|
|
39
39
|
}
|
package/lib/types.d.ts
CHANGED
|
@@ -110,6 +110,10 @@ export interface FireContext {
|
|
|
110
110
|
rule: DifyRule;
|
|
111
111
|
/** 发给 Dify 的 query:统一消息格式(消息触发为单行,窗口触发为逐行拼接) */
|
|
112
112
|
queryText: string;
|
|
113
|
-
/** content
|
|
113
|
+
/** content 变量的值:消息触发为剥离文本(剔除 @bot),窗口触发为精简行拼接 */
|
|
114
114
|
contentText: string;
|
|
115
|
+
/** 本次触发相关消息的图片 URL(合并/密度/定时的池或缓冲条目合集):供上传管线补齐非首条消息的图片 */
|
|
116
|
+
imageUrls?: string[];
|
|
117
|
+
/** 无 session 的主动触发(定时/密度到期)从消息池透传的私聊标记:会话绑定与 isDirect 变量用 */
|
|
118
|
+
isDirect?: boolean;
|
|
115
119
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Session } from 'koishi';
|
|
2
|
+
/** 图片占位符携带的 URL 摘要长度(hex 位) */
|
|
3
|
+
export declare const IMAGE_HASH_LENGTH = 8;
|
|
2
4
|
/**
|
|
3
5
|
* 会话文本提取的统一入口。此前有四个变体(条件匹配、历史记录、变量、@ 剥离)
|
|
4
6
|
* 各自实现「elements 有则取 text 元素,否则解析 content」,trim 与解析行为已经漂移,
|
|
@@ -6,21 +8,24 @@ import { Session } from 'koishi';
|
|
|
6
8
|
*/
|
|
7
9
|
/** 把可解析的消息文本(含元素标记)转纯文本;解析失败回退原文 */
|
|
8
10
|
export declare function parseTextSafe(content: string | undefined): string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
excludeAt?: boolean;
|
|
12
|
-
}
|
|
13
|
-
/** 会话消息纯文本:优先 elements,退回 content 解析;统一 trim */
|
|
14
|
-
export declare function extractText(session: Session, options?: ExtractTextOptions): string;
|
|
11
|
+
/** 会话消息纯文本:优先 elements,退回 content 解析;统一 trim(@ 元素不产生文本,仅供条件匹配) */
|
|
12
|
+
export declare function extractText(session: Session): string;
|
|
15
13
|
/** 消息中的图片元素在文本形态里的占位符(图片无 URL 时的兜底形态) */
|
|
16
14
|
export declare const IMAGE_PLACEHOLDER = "[\u56FE\u7247]";
|
|
17
|
-
/** 图片占位符带 URL
|
|
15
|
+
/** 图片占位符带 URL 摘要:同 URL 同标识,query 里的占位符与上传文件可全局对应 */
|
|
18
16
|
export declare function imagePlaceholder(url?: string): string;
|
|
19
17
|
/** 给被丢弃图片的占位符追加已丢弃注记:按 URL 摘要全局定位,无需与元素顺序对齐 */
|
|
20
18
|
export declare function annotateDroppedPlaceholders(text: string, droppedUrls: string[]): string;
|
|
19
|
+
interface ExtractVerbatimOptions {
|
|
20
|
+
/** 跳过 @ 机器人自身的渲染(content 变量语义:@bot 是触发结构而非消息内容) */
|
|
21
|
+
excludeBotAt?: boolean;
|
|
22
|
+
}
|
|
21
23
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* 消息原貌文本:text 元素按序拼接,@ 元素渲染为 @名字(缺名字用 ID),
|
|
25
|
+
* img/image 元素渲染为 [image:URL摘要]——统一格式行(query/池/历史)用默认形态,
|
|
26
|
+
* content 变量通道用 excludeBotAt 形态剔除 @bot。
|
|
24
27
|
*/
|
|
25
|
-
export declare function extractTextWithPlaceholders(session: Session): string;
|
|
28
|
+
export declare function extractTextWithPlaceholders(session: Session, options?: ExtractVerbatimOptions): string;
|
|
29
|
+
/** 消息中图片元素的 URL 列表(按元素序,与原貌文本中 [image:摘要] 的出现序一致) */
|
|
30
|
+
export declare function extractImageUrls(session: Session): string[];
|
|
26
31
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-dify-ai",
|
|
3
3
|
"description": "Invoke Dify apps (chatflow / workflow / completion) in Koishi with triggers and conversation bindings",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7",
|
|
5
5
|
"author": "PinkElysia",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/PinkElysiaDev/dify-ai-plugin-for-Koishi#readme",
|