ylib-openclaw-weixin 2.1.7-beta.13 → 2.1.7-beta.14
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/package.json
CHANGED
|
@@ -368,6 +368,456 @@ function buildEndpointCodeErrorMessage(
|
|
|
368
368
|
return parts.join(", ") || "unknown";
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
function parseJsonObjectSafely(raw: string): Record<string, unknown> | null {
|
|
372
|
+
// 统一 JSON 安全解析:
|
|
373
|
+
// - 仅保留对象类型(数组/原始值视为无效);
|
|
374
|
+
// - 解析失败返回 null,不抛错,避免打断 SSE 消费链路;
|
|
375
|
+
// - 用于外层 payload 与 eventContent 二次 JSON 解包。
|
|
376
|
+
try {
|
|
377
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
378
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
379
|
+
return parsed as Record<string, unknown>;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function parseActionEventContentObject(
|
|
388
|
+
payload: Record<string, unknown>,
|
|
389
|
+
): Record<string, unknown> | null {
|
|
390
|
+
// Python 侧 `_serialize_for_sse` 会把 `eventContent` 作为 JSON 字符串输出。
|
|
391
|
+
// 这里展开后,才能稳定读取 toolName/actionType 等字段识别“确认 action”。
|
|
392
|
+
// 同时兼容 eventContent 已经是对象的场景。
|
|
393
|
+
const eventContent = payload.eventContent;
|
|
394
|
+
if (typeof eventContent === "string") {
|
|
395
|
+
return parseJsonObjectSafely(eventContent);
|
|
396
|
+
}
|
|
397
|
+
if (
|
|
398
|
+
eventContent &&
|
|
399
|
+
typeof eventContent === "object" &&
|
|
400
|
+
!Array.isArray(eventContent)
|
|
401
|
+
) {
|
|
402
|
+
return eventContent as Record<string, unknown>;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function toReadableActionStatus(rawStatus: unknown): string {
|
|
408
|
+
// 状态字段中文化,便于在 IM 客户端里快速阅读。
|
|
409
|
+
// 未知状态保持原始值,避免误判。
|
|
410
|
+
const status =
|
|
411
|
+
typeof rawStatus === "string" ? rawStatus.trim().toLowerCase() : "";
|
|
412
|
+
if (!status) return "未知";
|
|
413
|
+
if (status === "running") return "进行中";
|
|
414
|
+
if (status === "pending") return "待确认";
|
|
415
|
+
if (status === "completed") return "已完成";
|
|
416
|
+
if (status === "failed") return "失败";
|
|
417
|
+
if (status === "cancelled") return "已取消";
|
|
418
|
+
return status;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function toReadableConfirmationType(rawType: unknown): string {
|
|
422
|
+
const type = typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
|
|
423
|
+
if (!type) return "未知";
|
|
424
|
+
if (type === "single_choice" || type === "single-choice" || type === "radio") {
|
|
425
|
+
return "单选";
|
|
426
|
+
}
|
|
427
|
+
if (
|
|
428
|
+
type === "multiple_choice" ||
|
|
429
|
+
type === "multi_choice" ||
|
|
430
|
+
type === "multiple-choice" ||
|
|
431
|
+
type === "checkbox"
|
|
432
|
+
) {
|
|
433
|
+
return "多选";
|
|
434
|
+
}
|
|
435
|
+
return type;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function normalizeConfirmationStatus(rawStatus: unknown): string {
|
|
439
|
+
return typeof rawStatus === "string" ? rawStatus.trim().toLowerCase() : "";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isPendingConfirmationStatus(rawStatus: unknown): boolean {
|
|
443
|
+
return normalizeConfirmationStatus(rawStatus) === "pending";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function resolveSseEventType(
|
|
447
|
+
currentEventType: string,
|
|
448
|
+
eventTypeFromPayload: string,
|
|
449
|
+
): string {
|
|
450
|
+
const current = String(currentEventType || "").trim().toLowerCase();
|
|
451
|
+
const fromPayload = String(eventTypeFromPayload || "").trim().toLowerCase();
|
|
452
|
+
if ((!current || current === "message") && fromPayload) return fromPayload;
|
|
453
|
+
return current || fromPayload;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function getResponseIdFromPayload(payload: Record<string, unknown>): string {
|
|
457
|
+
const camel = toDisplayText(payload.responseId);
|
|
458
|
+
if (camel) return camel;
|
|
459
|
+
return toDisplayText(payload.response_id);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function parseSseEventPayloadWithType(
|
|
463
|
+
currentEventType: string,
|
|
464
|
+
rawData: string,
|
|
465
|
+
): { payload: Record<string, unknown>; eventType: string; responseId: string } | null {
|
|
466
|
+
const payload = parseJsonObjectSafely(rawData);
|
|
467
|
+
if (!payload) return null;
|
|
468
|
+
const eventTypeFromPayload =
|
|
469
|
+
typeof payload.eventType === "string" ? payload.eventType.trim() : "";
|
|
470
|
+
const eventType = resolveSseEventType(currentEventType, eventTypeFromPayload);
|
|
471
|
+
const responseId = getResponseIdFromPayload(payload);
|
|
472
|
+
return { payload, eventType, responseId };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isLikelyConfirmationPayload(payload: Record<string, unknown>): boolean {
|
|
476
|
+
// 仅处理“待确认”状态:未标状态或非 pending 一律忽略。
|
|
477
|
+
if (!isPendingConfirmationStatus(payload.status)) return false;
|
|
478
|
+
const title = toDisplayText(payload.title);
|
|
479
|
+
const content = toDisplayText(payload.content);
|
|
480
|
+
const merged = `${title}\n${content}`.trim();
|
|
481
|
+
if (!merged) return false;
|
|
482
|
+
return merged.includes("确认") && merged.includes("选项");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildConfirmationSummaryFromLooseText(text: string): string | null {
|
|
486
|
+
const normalized = String(text || "").replace(/\r/g, "\n").trim();
|
|
487
|
+
if (!normalized) return null;
|
|
488
|
+
|
|
489
|
+
let question = normalized;
|
|
490
|
+
let optionsPart = "";
|
|
491
|
+
const optionAnchor =
|
|
492
|
+
normalized.match(/选项(?:分别)?(?:为|是)\s*[::]\s*([\s\S]+)/) ||
|
|
493
|
+
normalized.match(/选项\s*[::]\s*([\s\S]+)/);
|
|
494
|
+
if (optionAnchor) {
|
|
495
|
+
optionsPart = String(optionAnchor[1] || "").trim();
|
|
496
|
+
question = normalized.slice(0, optionAnchor.index ?? normalized.length).trim();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const rawOptions = optionsPart
|
|
500
|
+
? optionsPart
|
|
501
|
+
.split(/[、,,;;\n]+/)
|
|
502
|
+
.map((item) => item.trim())
|
|
503
|
+
.filter(Boolean)
|
|
504
|
+
: [];
|
|
505
|
+
const uniqueOptions = Array.from(new Set(rawOptions));
|
|
506
|
+
const choiceType =
|
|
507
|
+
/多选/.test(normalized) || /multiple/.test(normalized.toLowerCase())
|
|
508
|
+
? "多选"
|
|
509
|
+
: "单选";
|
|
510
|
+
|
|
511
|
+
const lines: string[] = ["【用户确认请求】", "状态:待确认", "", "确认请求列表:", ""];
|
|
512
|
+
lines.push(`问题 1:${question || "确认请求"}`);
|
|
513
|
+
lines.push(`类型:${choiceType}`);
|
|
514
|
+
if (question) lines.push(`问题内容:${question}`);
|
|
515
|
+
if (uniqueOptions.length > 0) {
|
|
516
|
+
lines.push("选项:");
|
|
517
|
+
uniqueOptions.forEach((opt, idx) => {
|
|
518
|
+
lines.push(`${idx + 1}. ${opt}`);
|
|
519
|
+
});
|
|
520
|
+
} else {
|
|
521
|
+
lines.push("选项:无");
|
|
522
|
+
}
|
|
523
|
+
lines.push("", "确认问题数:1");
|
|
524
|
+
return lines.join("\n").trim();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
|
528
|
+
// 运行时类型保护:只允许“非数组对象”按 Record 访问字段。
|
|
529
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
530
|
+
return value as Record<string, unknown>;
|
|
531
|
+
}
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function toDisplayText(value: unknown): string {
|
|
536
|
+
// 将可能来自后端的字段统一转成可显示文本。
|
|
537
|
+
// number/boolean 也会转成字符串,降低调用方分支复杂度。
|
|
538
|
+
if (value == null) return "";
|
|
539
|
+
if (typeof value === "string") return value.trim();
|
|
540
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
541
|
+
return String(value);
|
|
542
|
+
}
|
|
543
|
+
return "";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function buildReadableResourcePreviewLines(
|
|
547
|
+
resourcesRaw: unknown,
|
|
548
|
+
): string[] {
|
|
549
|
+
const lines: string[] = [];
|
|
550
|
+
if (!Array.isArray(resourcesRaw) || resourcesRaw.length === 0) {
|
|
551
|
+
return lines;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// 最佳实践:资源信息仅保留关键字段,并对长文本做摘要截断。
|
|
555
|
+
lines.push(" - 关联资源:");
|
|
556
|
+
resourcesRaw.forEach((resourceItem, idx) => {
|
|
557
|
+
const resource = asObjectRecord(resourceItem);
|
|
558
|
+
if (!resource) return;
|
|
559
|
+
|
|
560
|
+
const resourceId = toDisplayText(resource.id || resource.resource_id);
|
|
561
|
+
const resourceType =
|
|
562
|
+
toDisplayText(resource.type || resource.resource_type) || "unknown";
|
|
563
|
+
const resourceTitle = toDisplayText(resource.title) || "无标题";
|
|
564
|
+
const resourceContent = toDisplayText(resource.content);
|
|
565
|
+
const resourceUrl = toDisplayText(resource.url || resource.full_url);
|
|
566
|
+
|
|
567
|
+
const contentPreview =
|
|
568
|
+
resourceContent.length > 80
|
|
569
|
+
? `${resourceContent.slice(0, 80)}...`
|
|
570
|
+
: resourceContent;
|
|
571
|
+
const headerBits = [`${idx + 1}. ${resourceTitle}`, `类型=${resourceType}`];
|
|
572
|
+
if (resourceId) headerBits.push(`ID=${resourceId}`);
|
|
573
|
+
lines.push(` ${headerBits.join(" | ")}`);
|
|
574
|
+
if (resourceUrl) lines.push(` 链接: ${resourceUrl}`);
|
|
575
|
+
if (contentPreview) lines.push(` 内容摘要: ${contentPreview}`);
|
|
576
|
+
});
|
|
577
|
+
return lines;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function buildReadableConfirmationRequests(
|
|
581
|
+
metadata: Record<string, unknown> | null,
|
|
582
|
+
): string[] {
|
|
583
|
+
const lines: string[] = [];
|
|
584
|
+
const requestsRaw = metadata?.requests;
|
|
585
|
+
if (!Array.isArray(requestsRaw) || requestsRaw.length === 0) {
|
|
586
|
+
return lines;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 最佳实践:
|
|
590
|
+
// - 每个确认请求使用稳定编号;
|
|
591
|
+
// - 明确“问题/可选项”;
|
|
592
|
+
// - 对每个选项保留 value(供程序化回填)与 label(供人读)。
|
|
593
|
+
lines.push("确认请求列表:", "");
|
|
594
|
+
requestsRaw.forEach((requestItem, index) => {
|
|
595
|
+
const request = asObjectRecord(requestItem);
|
|
596
|
+
if (!request) return;
|
|
597
|
+
|
|
598
|
+
const type = toDisplayText(request.type);
|
|
599
|
+
const title = toDisplayText(request.title) || `确认请求 ${index + 1}`;
|
|
600
|
+
const question = toDisplayText(request.question);
|
|
601
|
+
|
|
602
|
+
lines.push(`问题 ${index + 1}:${title}`, "");
|
|
603
|
+
if (type) lines.push(`类型:${toReadableConfirmationType(type)}`, "");
|
|
604
|
+
if (question) lines.push(`问题内容:${question}`, "");
|
|
605
|
+
|
|
606
|
+
const optionsRaw = request.options;
|
|
607
|
+
if (Array.isArray(optionsRaw) && optionsRaw.length > 0) {
|
|
608
|
+
lines.push("选项:", "");
|
|
609
|
+
optionsRaw.forEach((optionItem, optionIndex) => {
|
|
610
|
+
const option = asObjectRecord(optionItem);
|
|
611
|
+
const rawLabel = option ? option.label : undefined;
|
|
612
|
+
const rawValue = option ? option.value : optionItem;
|
|
613
|
+
const optionLabel = toDisplayText(rawLabel) || toDisplayText(rawValue);
|
|
614
|
+
const displayLabel = optionLabel || `选项${optionIndex + 1}`;
|
|
615
|
+
|
|
616
|
+
lines.push(`${optionIndex + 1}. ${displayLabel}`);
|
|
617
|
+
|
|
618
|
+
const resourceLines = buildReadableResourcePreviewLines(option?.resources);
|
|
619
|
+
lines.push(...resourceLines);
|
|
620
|
+
});
|
|
621
|
+
} else {
|
|
622
|
+
lines.push("选项:无");
|
|
623
|
+
}
|
|
624
|
+
lines.push("");
|
|
625
|
+
});
|
|
626
|
+
return lines;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function extractConfirmationActionPlainText(
|
|
630
|
+
currentEventType: string,
|
|
631
|
+
rawData: string,
|
|
632
|
+
): string | null {
|
|
633
|
+
// Step 1: 解析 SSE data 的外层 payload(来自 Python `_serialize_for_sse`)。
|
|
634
|
+
const payload = parseJsonObjectSafely(rawData);
|
|
635
|
+
if (!payload) return null;
|
|
636
|
+
|
|
637
|
+
// 优先使用 SSE event 行;若缺失则回退 data 内 eventType。
|
|
638
|
+
const eventTypeFromPayload =
|
|
639
|
+
typeof payload.eventType === "string" ? payload.eventType.trim() : "";
|
|
640
|
+
const resolvedEventType = resolveSseEventType(
|
|
641
|
+
currentEventType,
|
|
642
|
+
eventTypeFromPayload,
|
|
643
|
+
);
|
|
644
|
+
if (resolvedEventType !== "action") return null;
|
|
645
|
+
|
|
646
|
+
// Step 2: 解包 action 内容(eventContent),读取识别与展示所需字段。
|
|
647
|
+
const actionPayload = parseActionEventContentObject(payload) ?? payload;
|
|
648
|
+
const toolName =
|
|
649
|
+
typeof actionPayload.toolName === "string"
|
|
650
|
+
? actionPayload.toolName.trim().toLowerCase()
|
|
651
|
+
: "";
|
|
652
|
+
const actionType =
|
|
653
|
+
typeof actionPayload.actionType === "string"
|
|
654
|
+
? actionPayload.actionType.trim().toLowerCase()
|
|
655
|
+
: "";
|
|
656
|
+
const normalizedStatus = normalizeConfirmationStatus(actionPayload.status);
|
|
657
|
+
|
|
658
|
+
// Python HITL 确认动作是 request_confirmation;仅该类型需要回发会话。
|
|
659
|
+
// 其他 action 保持静默,避免把工具执行噪音写入用户对话。
|
|
660
|
+
if (toolName !== "request_confirmation" && actionType !== "request_confirmation") {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
// 仅处理“待确认”状态;running/completed/failed/cancelled 全部忽略。
|
|
664
|
+
if (normalizedStatus !== "pending") return null;
|
|
665
|
+
|
|
666
|
+
const title =
|
|
667
|
+
typeof actionPayload.title === "string" ? actionPayload.title.trim() : "";
|
|
668
|
+
const description =
|
|
669
|
+
typeof actionPayload.description === "string"
|
|
670
|
+
? actionPayload.description.trim()
|
|
671
|
+
: "";
|
|
672
|
+
const toolCallId =
|
|
673
|
+
typeof actionPayload.toolCallId === "string"
|
|
674
|
+
? actionPayload.toolCallId.trim()
|
|
675
|
+
: "";
|
|
676
|
+
const responseId =
|
|
677
|
+
typeof payload.responseId === "string" ? payload.responseId.trim() : "";
|
|
678
|
+
const requestId =
|
|
679
|
+
typeof payload.requestId === "string" ? payload.requestId.trim() : "";
|
|
680
|
+
const metadata = asObjectRecord(actionPayload.metadata);
|
|
681
|
+
|
|
682
|
+
const lines: string[] = ["【用户确认请求】", "", `状态:${toReadableActionStatus(actionPayload.status)}`, ""];
|
|
683
|
+
if (title) lines.push(`标题:${title}`);
|
|
684
|
+
if (description) lines.push(`说明:${description}`);
|
|
685
|
+
if (toolCallId || responseId || requestId) lines.push("");
|
|
686
|
+
|
|
687
|
+
const requestLines = buildReadableConfirmationRequests(metadata);
|
|
688
|
+
if (requestLines.length > 0) {
|
|
689
|
+
lines.push(...requestLines);
|
|
690
|
+
}
|
|
691
|
+
return lines.join("\n").trim();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function extractConfirmationRequestPlainText(
|
|
695
|
+
currentEventType: string,
|
|
696
|
+
rawData: string,
|
|
697
|
+
): string | null {
|
|
698
|
+
const parsed = parseSseEventPayloadWithType(currentEventType, rawData);
|
|
699
|
+
if (!parsed) return null;
|
|
700
|
+
const { payload, eventType: resolvedEventType } = parsed;
|
|
701
|
+
if (resolvedEventType !== "confirmation_request") return null;
|
|
702
|
+
|
|
703
|
+
const eventContent =
|
|
704
|
+
// 兼容两种 payload:
|
|
705
|
+
// 1) 完整包装:{ eventType, requestId, responseId, eventContent: "{...}" }
|
|
706
|
+
// 2) 网关透传:event=confirmation_request 且 data 直接为 { status, requests, confirmations }
|
|
707
|
+
parseActionEventContentObject(payload) ??
|
|
708
|
+
(typeof payload.eventContent === "string"
|
|
709
|
+
? parseJsonObjectSafely(payload.eventContent)
|
|
710
|
+
: asObjectRecord(payload.eventContent)) ??
|
|
711
|
+
asObjectRecord(payload);
|
|
712
|
+
if (!eventContent) return null;
|
|
713
|
+
const normalizedStatus = normalizeConfirmationStatus(eventContent.status);
|
|
714
|
+
if (normalizedStatus !== "pending") return null;
|
|
715
|
+
const status = normalizedStatus;
|
|
716
|
+
const requests = Array.isArray(eventContent.requests)
|
|
717
|
+
? eventContent.requests
|
|
718
|
+
: [];
|
|
719
|
+
const responseId = getResponseIdFromPayload(payload);
|
|
720
|
+
const requestId =
|
|
721
|
+
typeof payload.requestId === "string" ? payload.requestId.trim() : "";
|
|
722
|
+
|
|
723
|
+
const lines: string[] = [
|
|
724
|
+
"【用户确认请求】",
|
|
725
|
+
`状态:${toReadableActionStatus(status)}`,
|
|
726
|
+
"",
|
|
727
|
+
];
|
|
728
|
+
if (responseId || requestId) lines.push("");
|
|
729
|
+
const requestLines = buildReadableConfirmationRequests({ requests });
|
|
730
|
+
if (requestLines.length > 0) lines.push(...requestLines);
|
|
731
|
+
lines.push(`确认问题数:${requests.length}`);
|
|
732
|
+
return lines.join("\n").trim();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function extractGenericSseEventPlainText(
|
|
736
|
+
currentEventType: string,
|
|
737
|
+
rawData: string,
|
|
738
|
+
): string | null {
|
|
739
|
+
const parsed = parseSseEventPayloadWithType(currentEventType, rawData);
|
|
740
|
+
if (!parsed) return null;
|
|
741
|
+
const { payload, eventType } = parsed;
|
|
742
|
+
if (!eventType) return null;
|
|
743
|
+
if (
|
|
744
|
+
eventType === "message" ||
|
|
745
|
+
eventType === "resource" ||
|
|
746
|
+
eventType === "error" ||
|
|
747
|
+
eventType === "action" ||
|
|
748
|
+
eventType === "confirmation_request"
|
|
749
|
+
) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const eventContent =
|
|
754
|
+
typeof payload.eventContent === "string"
|
|
755
|
+
? parseJsonObjectSafely(payload.eventContent) ?? payload.eventContent
|
|
756
|
+
: payload.eventContent;
|
|
757
|
+
const responseId = getResponseIdFromPayload(payload);
|
|
758
|
+
const requestId =
|
|
759
|
+
typeof payload.requestId === "string" ? payload.requestId.trim() : "";
|
|
760
|
+
if ((eventType === "title" || eventType === "tool_wait") && isLikelyConfirmationPayload(payload)) {
|
|
761
|
+
const looseText =
|
|
762
|
+
toDisplayText(payload.content) ||
|
|
763
|
+
toDisplayText(payload.title) ||
|
|
764
|
+
(typeof eventContent === "string" ? eventContent : "");
|
|
765
|
+
const confirmationSummary = buildConfirmationSummaryFromLooseText(looseText);
|
|
766
|
+
if (confirmationSummary) return confirmationSummary;
|
|
767
|
+
}
|
|
768
|
+
const lines: string[] = ["【系统事件】", `类型: ${eventType}`];
|
|
769
|
+
if (responseId) lines.push(`responseId: ${responseId}`);
|
|
770
|
+
if (requestId) lines.push(`requestId: ${requestId}`);
|
|
771
|
+
if (eventContent && typeof eventContent === "object") {
|
|
772
|
+
const contentObj = eventContent as Record<string, unknown>;
|
|
773
|
+
const title = toDisplayText(contentObj.title || contentObj.content);
|
|
774
|
+
const status = toDisplayText(contentObj.status);
|
|
775
|
+
if (title) lines.push(`标题: ${title}`);
|
|
776
|
+
if (status) lines.push(`状态: ${toReadableActionStatus(status)}`);
|
|
777
|
+
} else if (typeof eventContent === "string" && eventContent.trim()) {
|
|
778
|
+
lines.push(`内容: ${eventContent.trim()}`);
|
|
779
|
+
}
|
|
780
|
+
return lines.join("\n");
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function extractConfirmationDedupKey(
|
|
784
|
+
currentEventType: string,
|
|
785
|
+
rawData: string,
|
|
786
|
+
): string | null {
|
|
787
|
+
const parsed = parseSseEventPayloadWithType(currentEventType, rawData);
|
|
788
|
+
if (!parsed) return null;
|
|
789
|
+
const { payload, eventType, responseId } = parsed;
|
|
790
|
+
if (!responseId) return null;
|
|
791
|
+
if (eventType === "confirmation_request") {
|
|
792
|
+
const eventContent =
|
|
793
|
+
(typeof payload.eventContent === "string"
|
|
794
|
+
? parseJsonObjectSafely(payload.eventContent)
|
|
795
|
+
: asObjectRecord(payload.eventContent)) ?? asObjectRecord(payload);
|
|
796
|
+
if (!eventContent || !isPendingConfirmationStatus(eventContent.status)) return null;
|
|
797
|
+
return `confirmation_request:${responseId}`;
|
|
798
|
+
}
|
|
799
|
+
if (eventType === "action") {
|
|
800
|
+
const actionPayload = parseActionEventContentObject(payload) ?? payload;
|
|
801
|
+
const toolName =
|
|
802
|
+
typeof actionPayload.toolName === "string"
|
|
803
|
+
? actionPayload.toolName.trim().toLowerCase()
|
|
804
|
+
: "";
|
|
805
|
+
const actionType =
|
|
806
|
+
typeof actionPayload.actionType === "string"
|
|
807
|
+
? actionPayload.actionType.trim().toLowerCase()
|
|
808
|
+
: "";
|
|
809
|
+
if (!isPendingConfirmationStatus(actionPayload.status)) return null;
|
|
810
|
+
if (toolName === "request_confirmation" || actionType === "request_confirmation") {
|
|
811
|
+
return `action_confirmation:${responseId}`;
|
|
812
|
+
}
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
if ((eventType === "title" || eventType === "tool_wait") && isLikelyConfirmationPayload(payload)) {
|
|
816
|
+
return `loose_confirmation:${responseId}`;
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
371
821
|
function buildYuceMediaPublicUrl(uploadHost: string, dataPath: string): string {
|
|
372
822
|
const base = uploadHost.replace(/\/+$/, "");
|
|
373
823
|
if (!dataPath) return base;
|
|
@@ -537,7 +987,12 @@ async function streamFromEndpoint(params: {
|
|
|
537
987
|
mediaPath?: string;
|
|
538
988
|
mediaType?: string;
|
|
539
989
|
mediaFileName?: string;
|
|
540
|
-
|
|
990
|
+
onConfirmationAction?: (text: string) => Promise<void>;
|
|
991
|
+
}): Promise<{
|
|
992
|
+
text: string;
|
|
993
|
+
resources: EndpointResource[];
|
|
994
|
+
confirmationMessages: string[];
|
|
995
|
+
}> {
|
|
541
996
|
const gatewayUrl = `${params.endpointConfig.gatewayBaseUrl.replace(/\/+$/, "")}/v1/im/chat/completions`;
|
|
542
997
|
const messages: Array<{ role: string; content: string }> = [
|
|
543
998
|
{
|
|
@@ -631,18 +1086,21 @@ async function streamFromEndpoint(params: {
|
|
|
631
1086
|
return {
|
|
632
1087
|
text: String(parsed.choices?.[0]?.message?.content || ""),
|
|
633
1088
|
resources: [],
|
|
1089
|
+
confirmationMessages: [],
|
|
634
1090
|
};
|
|
635
1091
|
} catch {
|
|
636
|
-
return { text: text.trim(), resources: [] };
|
|
1092
|
+
return { text: text.trim(), resources: [], confirmationMessages: [] };
|
|
637
1093
|
}
|
|
638
1094
|
}
|
|
639
1095
|
|
|
640
1096
|
const resources: EndpointResource[] = [];
|
|
1097
|
+
const confirmationMessages: string[] = [];
|
|
641
1098
|
let accumulatedText = "";
|
|
642
1099
|
const reader = response.body.getReader();
|
|
643
1100
|
const decoder = new TextDecoder();
|
|
644
1101
|
let buffer = "";
|
|
645
1102
|
let currentEventType = "message";
|
|
1103
|
+
const handledConfirmationKeys = new Set<string>();
|
|
646
1104
|
let sseDone = false;
|
|
647
1105
|
|
|
648
1106
|
while (!sseDone) {
|
|
@@ -668,16 +1126,23 @@ async function streamFromEndpoint(params: {
|
|
|
668
1126
|
logger.info(
|
|
669
1127
|
`[weixin][endpoint][sse] event=${currentEventType} data=${data.slice(0, 400)}`,
|
|
670
1128
|
);
|
|
1129
|
+
let effectiveEventType = currentEventType;
|
|
1130
|
+
const dataPayload = parseJsonObjectSafely(data);
|
|
1131
|
+
const dataEventType =
|
|
1132
|
+
dataPayload && typeof dataPayload.eventType === "string"
|
|
1133
|
+
? dataPayload.eventType
|
|
1134
|
+
: "";
|
|
1135
|
+
effectiveEventType = resolveSseEventType(currentEventType, dataEventType);
|
|
671
1136
|
if (data === "[DONE]") {
|
|
672
1137
|
sseDone = true;
|
|
673
1138
|
break;
|
|
674
1139
|
}
|
|
675
1140
|
|
|
676
|
-
if (
|
|
1141
|
+
if (effectiveEventType === "error") {
|
|
677
1142
|
throw new Error(`endpoint error event: ${data}`);
|
|
678
1143
|
}
|
|
679
1144
|
|
|
680
|
-
if (
|
|
1145
|
+
if (effectiveEventType === "resource") {
|
|
681
1146
|
try {
|
|
682
1147
|
const parsed = JSON.parse(data) as { resources?: SseResource[] };
|
|
683
1148
|
for (const item of parsed.resources || []) {
|
|
@@ -707,6 +1172,99 @@ async function streamFromEndpoint(params: {
|
|
|
707
1172
|
continue;
|
|
708
1173
|
}
|
|
709
1174
|
|
|
1175
|
+
if (effectiveEventType === "confirmation_request") {
|
|
1176
|
+
const dedupKey = extractConfirmationDedupKey(effectiveEventType, data);
|
|
1177
|
+
if (dedupKey && handledConfirmationKeys.has(dedupKey)) {
|
|
1178
|
+
logger.info(
|
|
1179
|
+
`[weixin][endpoint][sse][confirmation_request] duplicate skipped: ${dedupKey}`,
|
|
1180
|
+
);
|
|
1181
|
+
// 去重后保持回到默认 message 状态,确保后续正文不会被丢弃。
|
|
1182
|
+
currentEventType = "message";
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (dedupKey) handledConfirmationKeys.add(dedupKey);
|
|
1186
|
+
const confirmationRequestText = extractConfirmationRequestPlainText(
|
|
1187
|
+
effectiveEventType,
|
|
1188
|
+
data,
|
|
1189
|
+
);
|
|
1190
|
+
if (confirmationRequestText) {
|
|
1191
|
+
// 与钉钉行为对齐:确认请求一旦到达就立即独立发送,
|
|
1192
|
+
// 不等待 SSE 全部结束,避免用户侧感知延迟。
|
|
1193
|
+
if (params.onConfirmationAction) {
|
|
1194
|
+
await params.onConfirmationAction(confirmationRequestText);
|
|
1195
|
+
} else {
|
|
1196
|
+
confirmationMessages.push(confirmationRequestText);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
currentEventType = "message";
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (effectiveEventType === "action") {
|
|
1204
|
+
// action 处理规则:
|
|
1205
|
+
// - 仅回发 request_confirmation;
|
|
1206
|
+
// - 输出字段顺序和前端确认面板一致,保证跨端认知一致。
|
|
1207
|
+
const dedupKey = extractConfirmationDedupKey(effectiveEventType, data);
|
|
1208
|
+
if (dedupKey && handledConfirmationKeys.has(dedupKey)) {
|
|
1209
|
+
logger.info(
|
|
1210
|
+
`[weixin][endpoint][sse][action] duplicate confirmation skipped: ${dedupKey}`,
|
|
1211
|
+
);
|
|
1212
|
+
// 去重后恢复默认事件类型,继续接收后续 content。
|
|
1213
|
+
currentEventType = "message";
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
const confirmationActionText = extractConfirmationActionPlainText(
|
|
1217
|
+
effectiveEventType,
|
|
1218
|
+
data,
|
|
1219
|
+
);
|
|
1220
|
+
if (confirmationActionText) {
|
|
1221
|
+
if (dedupKey) handledConfirmationKeys.add(dedupKey);
|
|
1222
|
+
// 与钉钉行为对齐:确认 action 事件同样即时发送。
|
|
1223
|
+
if (params.onConfirmationAction) {
|
|
1224
|
+
await params.onConfirmationAction(confirmationActionText);
|
|
1225
|
+
} else {
|
|
1226
|
+
confirmationMessages.push(confirmationActionText);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
currentEventType = "message";
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (effectiveEventType && effectiveEventType !== "message") {
|
|
1234
|
+
// 非确认类透传事件仅记录日志,不回发到会话,避免出现“【系统事件】 类型: title”。
|
|
1235
|
+
const dedupKey = extractConfirmationDedupKey(effectiveEventType, data);
|
|
1236
|
+
if (!dedupKey) {
|
|
1237
|
+
logger.info(
|
|
1238
|
+
`[weixin][endpoint][sse] passthrough ignored for outbound message: event=${effectiveEventType}`,
|
|
1239
|
+
);
|
|
1240
|
+
currentEventType = "message";
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const genericEventText = extractGenericSseEventPlainText(
|
|
1244
|
+
effectiveEventType,
|
|
1245
|
+
data,
|
|
1246
|
+
);
|
|
1247
|
+
if (genericEventText) {
|
|
1248
|
+
if (dedupKey && handledConfirmationKeys.has(dedupKey)) {
|
|
1249
|
+
logger.info(
|
|
1250
|
+
`[weixin][endpoint][sse][${effectiveEventType}] duplicate confirmation skipped: ${dedupKey}`,
|
|
1251
|
+
);
|
|
1252
|
+
// 去重后仍需继续累积后续正文 content。
|
|
1253
|
+
currentEventType = "message";
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (dedupKey) handledConfirmationKeys.add(dedupKey);
|
|
1257
|
+
// 仅确认语义的兜底透传事件会进入该分支,也即时发送保持时序一致。
|
|
1258
|
+
if (params.onConfirmationAction) {
|
|
1259
|
+
await params.onConfirmationAction(genericEventText);
|
|
1260
|
+
} else {
|
|
1261
|
+
confirmationMessages.push(genericEventText);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
currentEventType = "message";
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
710
1268
|
try {
|
|
711
1269
|
const parsed = JSON.parse(data) as {
|
|
712
1270
|
choices?: Array<{
|
|
@@ -745,6 +1303,7 @@ async function streamFromEndpoint(params: {
|
|
|
745
1303
|
return {
|
|
746
1304
|
text: accumulatedText,
|
|
747
1305
|
resources,
|
|
1306
|
+
confirmationMessages,
|
|
748
1307
|
};
|
|
749
1308
|
}
|
|
750
1309
|
|
|
@@ -1252,8 +1811,40 @@ export async function dispatchWeixinViaEndpoint(params: {
|
|
|
1252
1811
|
mediaPath: params.ctx.MediaPath,
|
|
1253
1812
|
mediaType: params.ctx.MediaType,
|
|
1254
1813
|
mediaFileName: params.ctx.MediaFileName,
|
|
1814
|
+
onConfirmationAction: async (confirmationText: string) => {
|
|
1815
|
+
const directText = String(confirmationText || "").trim();
|
|
1816
|
+
if (!directText) return;
|
|
1817
|
+
await sendMessageWeixin({
|
|
1818
|
+
to: params.ctx.To,
|
|
1819
|
+
text: directText,
|
|
1820
|
+
opts: {
|
|
1821
|
+
baseUrl: params.baseUrl,
|
|
1822
|
+
token: params.token,
|
|
1823
|
+
contextToken: params.contextToken,
|
|
1824
|
+
accountId: params.accountId,
|
|
1825
|
+
},
|
|
1826
|
+
});
|
|
1827
|
+
},
|
|
1255
1828
|
});
|
|
1256
1829
|
|
|
1830
|
+
// 确认 action 独立发送,不并入主回复文本。
|
|
1831
|
+
if (response.confirmationMessages.length > 0) {
|
|
1832
|
+
for (const confirmationText of response.confirmationMessages) {
|
|
1833
|
+
const directText = String(confirmationText || "").trim();
|
|
1834
|
+
if (!directText) continue;
|
|
1835
|
+
await sendMessageWeixin({
|
|
1836
|
+
to: params.ctx.To,
|
|
1837
|
+
text: directText,
|
|
1838
|
+
opts: {
|
|
1839
|
+
baseUrl: params.baseUrl,
|
|
1840
|
+
token: params.token,
|
|
1841
|
+
contextToken: params.contextToken,
|
|
1842
|
+
accountId: params.accountId,
|
|
1843
|
+
},
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1257
1848
|
const mediaDirectiveParsed = extractMediaDirectives(response.text || "");
|
|
1258
1849
|
const resourceMediaUrls = collectResourceMediaUrls(response.resources);
|
|
1259
1850
|
const allMediaUrls = dedupeUrls([
|
|
@@ -1284,9 +1875,12 @@ export async function dispatchWeixinViaEndpoint(params: {
|
|
|
1284
1875
|
.join("\n\n")
|
|
1285
1876
|
.trim();
|
|
1286
1877
|
|
|
1287
|
-
|
|
1878
|
+
// 与钉钉插件保持一致:当最终正文为空时,始终发送默认提示,
|
|
1879
|
+
// 不区分本轮是否已经发送过确认类独立消息。
|
|
1880
|
+
const outboundText = mergedText || "当前没有可展示的回复内容";
|
|
1881
|
+
if (outboundText) {
|
|
1288
1882
|
const filter = new StreamingMarkdownFilter();
|
|
1289
|
-
const filtered = filter.feed(
|
|
1883
|
+
const filtered = filter.feed(outboundText) + filter.flush();
|
|
1290
1884
|
await sendMessageWeixin({
|
|
1291
1885
|
to: params.ctx.To,
|
|
1292
1886
|
text: filtered,
|
|
@@ -1300,7 +1894,7 @@ export async function dispatchWeixinViaEndpoint(params: {
|
|
|
1300
1894
|
}
|
|
1301
1895
|
|
|
1302
1896
|
return {
|
|
1303
|
-
textLength:
|
|
1897
|
+
textLength: outboundText.length,
|
|
1304
1898
|
resourceCount: response.resources.length,
|
|
1305
1899
|
};
|
|
1306
1900
|
}
|