mirror of
https://mirror.skon.top/github.com/sliverp/qqbot
synced 2026-05-01 14:23:51 +08:00
Merge branch 'feat/stream_new1' into 'main' (merge request !6)
feat: v1.6.6 — 流式消息(C2C)+ 媒体发送队列重构 + ApiError 结构化错误
- 新增 StreamingController 流式消息控制器,C2C 私聊 AI 回复以打字机效果实时推送
- 新增 sendC2CStreamMessage 流式 API 封装(/v2/users/{openid}/stream_messages)
- 新增 ApiError 结构化错误类,携带 HTTP status 和 path,支持按状态码重试/降级
- 新增 media-send.ts 公共媒体标签解析与发送队列,outbound.ts 和 streaming.ts 共用
- 新增 streaming/streamingConfig 账户配置项,支持按账户控制流式开关和节流间隔
- 新增 strip-incomplete-media-tag 和 streaming-controller 单元测试
- 重构 outbound.ts 媒体发送逻辑为调用公共模块,消除约 100 行重复代码
- audio-convert.ts 日志从 console.log 降级为 console.debug,减少生产日志噪音
- gateway.ts 集成流式控制器:per-message 创建、onPartialReply 回调、dispatch 后收尾
- 删除已清空的 user-messages.ts
- bump version to 1.6.6
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [1.6.6] - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Streaming messages (C2C)**: New `StreamingController` delivers AI responses as real-time typing-effect chunks in private chat. Includes throttle control (default 500ms, min 300ms), automatic media-tag pause/resume, long-gap batch window, state-machine lifecycle (`idle → streaming → completed/aborted`), and graceful fallback to static mode when the streaming API is unavailable.
|
||||
- **Stream message API `sendC2CStreamMessage`**: Low-level wrapper for QQ Open Platform `/v2/users/{openid}/stream_messages` endpoint, with `replace` input mode, incremental `msg_seq`/`index`, and `GENERATING`/`DONE` state signaling.
|
||||
- **`ApiError` structured error class**: API request errors now carry `status` (HTTP code) and `path`, enabling callers (e.g. streaming controller) to branch on status for retry vs. fallback decisions.
|
||||
- **Media send queue module `media-send.ts`**: Extracted media-tag parsing, path-encoding fix, and send-queue execution into a shared utility used by both `outbound.ts` (static mode) and `streaming.ts` (streaming mode), eliminating ~100 lines of duplication.
|
||||
- **Streaming configuration**: New `streaming` (boolean, default `true`) and `streamingConfig.throttleMs` options in account config for per-account streaming control.
|
||||
- **Unit tests**: Added `strip-incomplete-media-tag.test.ts` and `streaming-controller.test.ts`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Outbound media handling refactored**: `sendText` in `outbound.ts` now delegates media-tag parsing and queue execution to the shared `media-send.ts` module instead of inline regex + switch logic.
|
||||
- **Audio convert log level**: Downgraded `console.log` → `console.debug` for SILK detection, ffmpeg conversion, and WASM fallback logs in `audio-convert.ts`, reducing noise in production.
|
||||
- **Gateway streaming integration**: `gateway.ts` creates a `StreamingController` per inbound message when streaming is enabled; registers `onPartialReply` callback to feed incremental text into the controller; finalizes or aborts the stream after dispatch completes.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`user-messages.ts`**: Deleted the already-emptied module (design: plugin layer does not generate user-facing error text).
|
||||
|
||||
## [1.6.5] - 2026-03-24
|
||||
|
||||
### OpenClaw 3.23 Compatibility
|
||||
|
||||
@@ -4,6 +4,27 @@
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/)。
|
||||
|
||||
## [1.6.6] - 2026-03-25
|
||||
|
||||
### 新增
|
||||
|
||||
- **流式消息(C2C 私聊)**:新增 `StreamingController` 流式控制器,AI 回复以打字机效果实时逐步推送到 QQ 私聊。支持节流控制(默认 500ms,最小 300ms)、媒体标签自动暂停/恢复流式会话、长间隔批处理窗口、状态机生命周期管理(`idle → streaming → completed/aborted`),流式 API 不可用时自动降级为静态消息模式。
|
||||
- **流式消息 API `sendC2CStreamMessage`**:封装 QQ 开放平台 `/v2/users/{openid}/stream_messages` 接口,支持 `replace` 输入模式、递增 `msg_seq`/`index` 序号、`GENERATING`/`DONE` 状态信令。
|
||||
- **`ApiError` 结构化错误类**:API 请求错误现在携带 `status`(HTTP 状态码)和 `path`,使调用方(如流式控制器)可根据状态码决定重试或降级策略。
|
||||
- **媒体发送队列模块 `media-send.ts`**:将媒体标签解析、路径编码修复、发送队列执行器抽取为公共工具模块,供 `outbound.ts`(静态模式)和 `streaming.ts`(流式模式)共用,消除约 100 行重复代码。
|
||||
- **流式消息配置项**:账户配置新增 `streaming`(布尔值,默认 `true`)和 `streamingConfig.throttleMs` 选项,支持按账户控制流式消息开关和节流间隔。
|
||||
- **单元测试**:新增 `strip-incomplete-media-tag.test.ts` 和 `streaming-controller.test.ts`。
|
||||
|
||||
### 变更
|
||||
|
||||
- **出站媒体处理重构**:`outbound.ts` 中 `sendText` 的媒体标签解析和发送队列逻辑重构为调用公共 `media-send.ts` 模块,替代原有的内联正则 + switch 分支。
|
||||
- **音频转换日志降级**:`audio-convert.ts` 中 SILK 检测、ffmpeg 转换、WASM 降级等日志从 `console.log` 降为 `console.debug`,减少生产环境日志噪音。
|
||||
- **Gateway 流式集成**:`gateway.ts` 在流式启用时为每条入站消息创建 `StreamingController`;注册 `onPartialReply` 回调将增量文本馈入控制器;dispatch 完成后终结或中止流式会话。
|
||||
|
||||
### 移除
|
||||
|
||||
- **`user-messages.ts`**:删除已清空的模块(设计原则:插件层不生成面向用户的错误提示)。
|
||||
|
||||
## [1.6.5] - 2026-03-24
|
||||
|
||||
### OpenClaw 3.23 兼容适配
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tencent-connect/openclaw-qqbot",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -24,6 +24,7 @@ INSTALL_SRC=""
|
||||
TARGET_VERSION=""
|
||||
APPID=""
|
||||
SECRET=""
|
||||
STREAMING=""
|
||||
NO_RESTART=false
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -50,6 +51,7 @@ print_usage() {
|
||||
echo ""
|
||||
echo " --appid <appid> QQ机器人 appid(首次安装时必填)"
|
||||
echo " --secret <secret> QQ机器人 secret(首次安装时必填)"
|
||||
echo " --streaming <yes|no> 是否启用流式消息(默认: no,仅 C2C 私聊)"
|
||||
echo ""
|
||||
echo "也可以通过环境变量设置:"
|
||||
echo " QQBOT_APPID QQ机器人 appid"
|
||||
@@ -87,6 +89,11 @@ while [[ $# -gt 0 ]]; do
|
||||
SECRET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--streaming)
|
||||
[ -z "$2" ] && echo "❌ --streaming 需要参数" && exit 1
|
||||
STREAMING="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-restart)
|
||||
NO_RESTART=true
|
||||
shift 1
|
||||
@@ -449,6 +456,34 @@ elif [ -n "$APPID" ] || [ -n "$SECRET" ]; then
|
||||
echo "⚠️ --appid 和 --secret 必须同时提供"
|
||||
fi
|
||||
|
||||
# [配置] streaming(流式消息)
|
||||
if [ -n "$STREAMING" ]; then
|
||||
echo ""
|
||||
echo "[配置] 写入 streaming(流式消息)配置..."
|
||||
STREAMING_VALUE=""
|
||||
if [ "$STREAMING" = "yes" ] || [ "$STREAMING" = "y" ] || [ "$STREAMING" = "true" ]; then
|
||||
STREAMING_VALUE="true"
|
||||
else
|
||||
STREAMING_VALUE="false"
|
||||
fi
|
||||
|
||||
CONFIG_FILE="$HOME/.$CMD/$CMD.json"
|
||||
if [ -f "$CONFIG_FILE" ] && node -e "
|
||||
const fs = require('fs');
|
||||
const cfg = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8'));
|
||||
if (!cfg.channels) cfg.channels = {};
|
||||
if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
|
||||
const target = $STREAMING_VALUE;
|
||||
if (cfg.channels.qqbot.streaming === target) process.exit(0);
|
||||
cfg.channels.qqbot.streaming = target;
|
||||
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(cfg, null, 4) + '\n');
|
||||
" 2>&1; then
|
||||
echo " ✅ streaming 配置写入成功 (streaming=$STREAMING_VALUE)"
|
||||
else
|
||||
echo " ⚠️ streaming 配置写入失败,不影响后续运行"
|
||||
fi
|
||||
fi
|
||||
|
||||
# [4/4] 重启 gateway 使新版本生效
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ cd "$PROJ_DIR"
|
||||
APPID=""
|
||||
SECRET=""
|
||||
MARKDOWN=""
|
||||
STREAMING=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -40,6 +41,10 @@ while [[ $# -gt 0 ]]; do
|
||||
MARKDOWN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--streaming)
|
||||
STREAMING="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
@@ -47,6 +52,7 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --appid <appid> QQ机器人 appid"
|
||||
echo " --secret <secret> QQ机器人 secret"
|
||||
echo " --markdown <yes|no> 是否启用 markdown 消息格式(默认: no)"
|
||||
echo " --streaming <yes|no> 是否启用流式消息(默认: no,仅 C2C 私聊)"
|
||||
echo " -h, --help 显示帮助信息"
|
||||
echo ""
|
||||
echo "也可以通过环境变量设置:"
|
||||
@@ -54,6 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " QQBOT_SECRET QQ机器人 secret"
|
||||
echo " QQBOT_TOKEN QQ机器人 token (appid:secret)"
|
||||
echo " QQBOT_MARKDOWN 是否启用 markdown(yes/no)"
|
||||
echo " QQBOT_STREAMING 是否启用流式消息(yes/no)"
|
||||
echo ""
|
||||
echo "不带参数时,将使用已有配置直接启动。"
|
||||
echo ""
|
||||
@@ -72,6 +79,7 @@ done
|
||||
APPID="${APPID:-$QQBOT_APPID}"
|
||||
SECRET="${SECRET:-$QQBOT_SECRET}"
|
||||
MARKDOWN="${MARKDOWN:-$QQBOT_MARKDOWN}"
|
||||
STREAMING="${STREAMING:-$QQBOT_STREAMING}"
|
||||
|
||||
echo "========================================="
|
||||
echo " qqbot 一键更新启动脚本"
|
||||
@@ -131,6 +139,29 @@ if [ -z "$SAVED_QQBOT_TOKEN" ] && [ -d "$HOME/.openclaw" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 备份 streaming 配置(升级后恢复)
|
||||
SAVED_STREAMING=""
|
||||
for _app in openclaw clawdbot moltbot; do
|
||||
_cfg="$HOME/.$_app/$_app.json"
|
||||
if [ -f "$_cfg" ]; then
|
||||
SAVED_STREAMING=$(node -e "
|
||||
const cfg = JSON.parse(require('fs').readFileSync('$_cfg', 'utf8'));
|
||||
const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
|
||||
for (const key of keys) {
|
||||
const ch = cfg.channels && cfg.channels[key];
|
||||
if (ch && typeof ch.streaming === 'boolean') {
|
||||
process.stdout.write(String(ch.streaming));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
" 2>/dev/null || true)
|
||||
[ -n "$SAVED_STREAMING" ] && break
|
||||
fi
|
||||
done
|
||||
if [ -n "$SAVED_STREAMING" ]; then
|
||||
echo "已备份 streaming 配置: $SAVED_STREAMING"
|
||||
fi
|
||||
|
||||
# 2. 移除老版本
|
||||
echo ""
|
||||
echo "[2/6] 移除老版本..."
|
||||
@@ -635,6 +666,69 @@ else
|
||||
echo "未指定 markdown 选项,使用已有配置"
|
||||
fi
|
||||
|
||||
# 5.5. 配置 streaming 选项
|
||||
echo ""
|
||||
echo "[5.5/6] 配置 streaming(流式消息)选项..."
|
||||
|
||||
# 确定目标 streaming 值:命令行参数 > 备份值
|
||||
STREAMING_VALUE=""
|
||||
if [ -n "$STREAMING" ]; then
|
||||
if [ "$STREAMING" = "yes" ] || [ "$STREAMING" = "y" ] || [ "$STREAMING" = "true" ]; then
|
||||
STREAMING_VALUE="true"
|
||||
echo "启用流式消息..."
|
||||
else
|
||||
STREAMING_VALUE="false"
|
||||
echo "禁用流式消息..."
|
||||
fi
|
||||
elif [ -n "$SAVED_STREAMING" ]; then
|
||||
STREAMING_VALUE="$SAVED_STREAMING"
|
||||
echo "从备份恢复 streaming 配置: $SAVED_STREAMING"
|
||||
fi
|
||||
|
||||
if [ -n "$STREAMING_VALUE" ]; then
|
||||
CURRENT_STREAMING_VALUE=$(node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const home = process.env.HOME;
|
||||
for (const app of ['openclaw', 'clawdbot', 'moltbot']) {
|
||||
const f = path.join(home, '.' + app, app + '.json');
|
||||
if (!fs.existsSync(f)) continue;
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(f, 'utf8'));
|
||||
const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
|
||||
for (const key of keys) {
|
||||
const ch = cfg.channels && cfg.channels[key];
|
||||
if (!ch) continue;
|
||||
if (typeof ch.streaming === 'boolean') { process.stdout.write(String(ch.streaming)); process.exit(0); }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ "$CURRENT_STREAMING_VALUE" = "$STREAMING_VALUE" ]; then
|
||||
echo "✅ streaming 配置已是目标值,跳过写入"
|
||||
else
|
||||
OPENCLAW_CONFIG="$HOME/.openclaw/openclaw.json"
|
||||
if [ -f "$OPENCLAW_CONFIG" ] && node -e "
|
||||
const fs = require('fs');
|
||||
const cfg = JSON.parse(fs.readFileSync('$OPENCLAW_CONFIG', 'utf-8'));
|
||||
if (!cfg.channels) cfg.channels = {};
|
||||
if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
|
||||
const target = $STREAMING_VALUE;
|
||||
if (cfg.channels.qqbot.streaming === target) process.exit(0);
|
||||
cfg.channels.qqbot.streaming = target;
|
||||
fs.writeFileSync('$OPENCLAW_CONFIG', JSON.stringify(cfg, null, 4) + '\n');
|
||||
" 2>&1; then
|
||||
echo "✅ streaming 配置成功"
|
||||
_config_changed=1
|
||||
else
|
||||
echo "⚠️ streaming 配置设置失败,不影响后续运行"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "未指定 streaming 选项且无备份值,使用默认配置"
|
||||
fi
|
||||
|
||||
# 6. 启动 openclaw
|
||||
echo ""
|
||||
echo "[6/6] 启动 openclaw..."
|
||||
|
||||
61
src/api.ts
61
src/api.ts
@@ -7,6 +7,20 @@ import os from "node:os";
|
||||
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
|
||||
import { sanitizeFileName } from "./utils/platform.js";
|
||||
|
||||
// ============ 自定义错误 ============
|
||||
|
||||
/** API 请求错误,携带 HTTP status code */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly path: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = "https://api.sgroup.qq.com";
|
||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
|
||||
@@ -302,15 +316,15 @@ export async function apiRequest<T = unknown>(
|
||||
: res.status === 429
|
||||
? "请求过于频繁,已被限流"
|
||||
: `开放平台返回 HTTP ${res.status}`;
|
||||
throw new Error(`${statusHint}(${path}),请稍后重试`);
|
||||
throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
|
||||
}
|
||||
// JSON 错误响应
|
||||
try {
|
||||
const error = JSON.parse(rawBody) as { message?: string; code?: number };
|
||||
throw new Error(`API Error [${path}]: ${error.message ?? rawBody}`);
|
||||
throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof Error && parseErr.message.startsWith("API Error")) throw parseErr;
|
||||
throw new Error(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`);
|
||||
if (parseErr instanceof ApiError) throw parseErr;
|
||||
throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,3 +1050,42 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 流式消息 API ============
|
||||
|
||||
import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
|
||||
|
||||
/**
|
||||
* 发送流式消息(C2C 私聊)
|
||||
*
|
||||
* 流式协议:
|
||||
* - 首次调用时不传 stream_msg_id,由平台返回
|
||||
* - 后续分片携带 stream_msg_id 和递增 msg_seq
|
||||
* - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
|
||||
*
|
||||
* @param accessToken - access_token
|
||||
* @param openid - 用户 openid
|
||||
* @param req - 流式消息请求体
|
||||
* @returns 流式消息响应
|
||||
*/
|
||||
export async function sendC2CStreamMessage(
|
||||
accessToken: string,
|
||||
openid: string,
|
||||
req: StreamMessageRequest,
|
||||
): Promise<StreamMessageResponse> {
|
||||
const path = `/v2/users/${openid}/stream_messages`;
|
||||
const body: Record<string, unknown> = {
|
||||
input_mode: req.input_mode,
|
||||
input_state: req.input_state,
|
||||
content_type: req.content_type,
|
||||
content_raw: req.content_raw,
|
||||
event_id: req.event_id,
|
||||
msg_id: req.msg_id,
|
||||
msg_seq: req.msg_seq,
|
||||
index: req.index,
|
||||
};
|
||||
if (req.stream_msg_id) {
|
||||
body.stream_msg_id = req.stream_msg_id;
|
||||
}
|
||||
return apiRequest<StreamMessageResponse>(accessToken, "POST", path, body);
|
||||
}
|
||||
|
||||
@@ -70,17 +70,10 @@ export function resolveQQBotAccount(
|
||||
let secretSource: "config" | "file" | "env" | "none" = "none";
|
||||
|
||||
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
// 默认账户从顶层读取
|
||||
// 默认账户从顶层读取(展开所有字段,避免遗漏新增配置项)
|
||||
const { accounts: _accounts, ...topLevelConfig } = qqbot ?? {} as QQBotChannelConfig;
|
||||
accountConfig = {
|
||||
enabled: qqbot?.enabled,
|
||||
name: qqbot?.name,
|
||||
appId: qqbot?.appId,
|
||||
clientSecret: qqbot?.clientSecret,
|
||||
clientSecretFile: qqbot?.clientSecretFile,
|
||||
dmPolicy: qqbot?.dmPolicy,
|
||||
allowFrom: qqbot?.allowFrom,
|
||||
systemPrompt: qqbot?.systemPrompt,
|
||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||
...topLevelConfig,
|
||||
markdownSupport: qqbot?.markdownSupport ?? true,
|
||||
};
|
||||
appId = normalizeAppId(qqbot?.appId);
|
||||
|
||||
137
src/gateway.ts
137
src/gateway.ts
@@ -22,6 +22,7 @@ import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
|
||||
import { parseAndSendMediaTags, sendPlainReply, type DeliverEventContext, type DeliverAccountContext } from "./outbound-deliver.js";
|
||||
import { createDeliverDebouncer, type DeliverDebouncer } from "./deliver-debounce.js";
|
||||
import { runWithRequestContext } from "./request-context.js";
|
||||
import { StreamingController, shouldUseStreaming } from "./streaming.js";
|
||||
|
||||
// QQ Bot intents - 按权限级别分组
|
||||
const INTENTS = {
|
||||
@@ -50,6 +51,7 @@ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765
|
||||
// 使用绝对路径,确保文件保存和读取使用同一目录
|
||||
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
|
||||
|
||||
|
||||
export interface GatewayContext {
|
||||
account: ResolvedQQBotAccount;
|
||||
abortSignal: AbortSignal;
|
||||
@@ -849,6 +851,53 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
}, responseTimeout);
|
||||
});
|
||||
|
||||
|
||||
// ============ 流式消息控制器 ============
|
||||
const targetType = event.type === "c2c" ? "c2c" as const
|
||||
: event.type === "group" ? "group" as const
|
||||
: "channel" as const;
|
||||
const useStreaming = shouldUseStreaming(account, targetType);
|
||||
log?.info(`[qqbot:${account.accountId}] Streaming ${useStreaming ? "enabled" : "disabled"} for ${targetType} message from ${event.senderId}`);
|
||||
let streamingController: StreamingController | null = null;
|
||||
|
||||
/** 创建一个新的 StreamingController 实例(用于初始创建和回复边界时重建) */
|
||||
const createStreamingController = (): StreamingController => {
|
||||
const ctrl = new StreamingController({
|
||||
account,
|
||||
userId: event.senderId,
|
||||
replyToMsgId: event.messageId,
|
||||
eventId: event.messageId,
|
||||
logPrefix: `[qqbot:${account.accountId}:streaming]`,
|
||||
log,
|
||||
mediaContext: {
|
||||
account,
|
||||
event: {
|
||||
type: event.type as "c2c" | "group" | "channel",
|
||||
senderId: event.senderId,
|
||||
messageId: event.messageId,
|
||||
groupOpenid: event.groupOpenid,
|
||||
channelId: event.channelId,
|
||||
},
|
||||
log,
|
||||
},
|
||||
// 回复边界回调:终结旧 controller 后创建新的,用新回复文本继续流式
|
||||
onReplyBoundary: async (newReplyText: string) => {
|
||||
log?.info(`[qqbot:${account.accountId}] Reply boundary: creating new StreamingController for new reply`);
|
||||
const newCtrl = createStreamingController();
|
||||
streamingController = newCtrl;
|
||||
// 将新回复的初始文本交给新 controller 处理
|
||||
await newCtrl.onPartialReply({ text: newReplyText });
|
||||
},
|
||||
});
|
||||
return ctrl;
|
||||
};
|
||||
|
||||
if (useStreaming) {
|
||||
log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
|
||||
streamingController = createStreamingController();
|
||||
}
|
||||
|
||||
|
||||
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
@@ -956,6 +1005,37 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
|
||||
}
|
||||
|
||||
// ============ 流式模式处理 ============
|
||||
// 流式模式下,所有 block deliver 内容(含媒体标签)统一交由 StreamingController 处理。
|
||||
// StreamingController 内部有重试机制;如果一个分片都没发出去则降级到普通消息。
|
||||
if (streamingController && !streamingController.isTerminalPhase) {
|
||||
const deliverTextLen = (payload.text ?? "").length;
|
||||
const deliverPreview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver entry, textLen=${deliverTextLen}, phase=${streamingController.currentPhase}, sentChunks=${streamingController.sentChunkCount_debug}, preview="${deliverPreview}"`);
|
||||
try {
|
||||
await streamingController.onDeliver(payload);
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver done, phase=${streamingController.currentPhase}`);
|
||||
} catch (err) {
|
||||
// StreamingController 内部已有重试,这里只打日志
|
||||
log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
|
||||
}
|
||||
|
||||
// 检查是否因流式 API 不可用而需要降级(ensureStreamingStarted 全部失败)
|
||||
// 如果需要降级,不 return,让本次 deliver 的 payload.text(全量文本)继续走普通发送逻辑
|
||||
if (streamingController.shouldFallbackToStatic) {
|
||||
log?.info(`[qqbot:${account.accountId}] Streaming API unavailable, falling back to static for this deliver`);
|
||||
// 不 return,继续走普通发送逻辑(payload.text 是完整文本)
|
||||
} else {
|
||||
// 流式正常处理,不走普通发送逻辑
|
||||
pluginRuntime.channel.activity.record({
|
||||
channel: "qqbot",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 实际发送逻辑(可被 debouncer 包裹) ============
|
||||
const executeDeliver = async (deliverPayload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, _deliverInfo: { kind: string }) => {
|
||||
// ============ 引用回复 ============
|
||||
@@ -1044,6 +1124,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
// 流式模式:委托给 streaming controller 处理错误
|
||||
if (streamingController && !streamingController.isTerminalPhase) {
|
||||
try {
|
||||
await streamingController.onError(err);
|
||||
} catch (streamErr) {
|
||||
log?.error(`[qqbot:${account.accountId}] Streaming onError failed: ${streamErr}`);
|
||||
}
|
||||
|
||||
// 如果 onError 中因无分片发出而降级,不 return,走普通错误处理
|
||||
if (streamingController.shouldFallbackToStatic) {
|
||||
log?.info(`[qqbot:${account.accountId}] Streaming onError: no chunk sent, falling back to static error handling`);
|
||||
// 不 return,继续走普通错误处理
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const errMsg = String(err);
|
||||
|
||||
@@ -1062,7 +1159,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
disableBlockStreaming: true,
|
||||
// 流式模式时禁用 block streaming
|
||||
disableBlockStreaming: !useStreaming,
|
||||
// 流式模式下注册 onPartialReply 回调,接收流式文本增量
|
||||
...(streamingController ? {
|
||||
onPartialReply: async (payload: { text?: string }) => {
|
||||
const textLen = payload.text?.length ?? 0;
|
||||
const preview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
|
||||
log?.debug?.(`[qqbot:${account.accountId}] onPartialReply called, textLen=${textLen}, phase=${streamingController!.currentPhase}, isTerminal=${streamingController!.isTerminalPhase}, preview="${preview}"`);
|
||||
try {
|
||||
await streamingController!.onPartialReply(payload);
|
||||
log?.debug?.(`[qqbot:${account.accountId}] onPartialReply done, phase=${streamingController!.currentPhase}`);
|
||||
} catch (err) {
|
||||
// StreamingController 内部已有重试,这里只打日志
|
||||
log?.error(`[qqbot:${account.accountId}] Streaming onPartialReply error: ${err}`);
|
||||
}
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1093,6 +1206,28 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
await debouncer.dispose();
|
||||
debouncer = null;
|
||||
}
|
||||
|
||||
// ============ 流式消息收尾 ============
|
||||
// dispatch 完成后,标记流式控制器已完成并触发 onIdle(发送终结分片)
|
||||
if (streamingController && !streamingController.isTerminalPhase) {
|
||||
try {
|
||||
streamingController.markFullyComplete();
|
||||
await streamingController.onIdle();
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Streaming controller finalized`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Streaming finalization error: ${err}`);
|
||||
// 尝试中止
|
||||
try { await streamingController.abortStreaming(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 流式降级到非流式 ============
|
||||
// 无需额外处理:如果流式 API 不可用(shouldFallbackToStatic),
|
||||
// deliver 回调中已自动跳过流式拦截,走普通消息发送逻辑。
|
||||
// (每次 deliver 收到的都是全量文本,不需要在 controller 内部保存累积文本)
|
||||
if (streamingController?.shouldFallbackToStatic) {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errStr = String(err);
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
||||
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
|
||||
import { sendPhoto, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
|
||||
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
|
||||
import { normalizeMediaTags } from "./utils/media-tags.js";
|
||||
import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js";
|
||||
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
|
||||
import { isLocalPath as isLocalFilePath } from "./utils/platform.js";
|
||||
import { filterInternalMarkers } from "./utils/text-parsing.js";
|
||||
|
||||
// ============ 类型定义 ============
|
||||
@@ -60,56 +60,16 @@ export async function parseAndSendMediaTags(
|
||||
const { account, log } = actx;
|
||||
const prefix = `[qqbot:${account.accountId}]`;
|
||||
|
||||
// 预处理:纠正小模型常见的标签拼写错误和格式问题
|
||||
const text = normalizeMediaTags(replyText);
|
||||
// 使用 media-send.ts 的统一解析器(内含 normalizeMediaTags + 路径编码修复)
|
||||
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(replyText, log);
|
||||
|
||||
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
|
||||
|
||||
if (mediaTagMatches.length === 0) {
|
||||
return { handled: false, normalizedText: text };
|
||||
}
|
||||
|
||||
const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1]!.toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {} as Record<string, number>);
|
||||
log?.info(`${prefix} Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
|
||||
|
||||
// 构建发送队列
|
||||
type QueueItem = { type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string };
|
||||
const sendQueue: QueueItem[] = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
const regex2 = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = regex2.exec(text)) !== null) {
|
||||
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (textBefore) {
|
||||
sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
|
||||
}
|
||||
|
||||
const tagName = match[1]!.toLowerCase();
|
||||
let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix);
|
||||
|
||||
if (mediaPath) {
|
||||
const typeMap: Record<string, QueueItem["type"]> = {
|
||||
qqmedia: "media", qqvoice: "voice", qqvideo: "video", qqfile: "file",
|
||||
};
|
||||
const itemType = typeMap[tagName] ?? "image";
|
||||
sendQueue.push({ type: itemType, content: mediaPath });
|
||||
log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (textAfter) {
|
||||
sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
|
||||
if (!hasMedia || sendQueue.length === 0) {
|
||||
return { handled: false, normalizedText: replyText };
|
||||
}
|
||||
|
||||
log?.info(`${prefix} Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
||||
|
||||
// 按顺序发送
|
||||
// 构建统一的媒体发送上下文
|
||||
const mediaTarget: MediaTargetContext = {
|
||||
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
||||
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
|
||||
@@ -118,46 +78,22 @@ export async function parseAndSendMediaTags(
|
||||
logPrefix: prefix,
|
||||
};
|
||||
|
||||
for (const item of sendQueue) {
|
||||
if (item.type === "text") {
|
||||
await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef);
|
||||
} else if (item.type === "image") {
|
||||
const result = await sendPhoto(mediaTarget, item.content);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendPhoto error: ${result.error}`);
|
||||
await sendTextChunks(`发送图片失败:${result.error}`, event, actx, sendWithRetry, consumeQuoteRef);
|
||||
}
|
||||
} else if (item.type === "voice") {
|
||||
await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix);
|
||||
} else if (item.type === "video") {
|
||||
const result = await sendVideoMsg(mediaTarget, item.content);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
|
||||
await sendTextChunks(`发送视频失败:${result.error}`, event, actx, sendWithRetry, consumeQuoteRef);
|
||||
}
|
||||
} else if (item.type === "file") {
|
||||
const result = await sendDocument(mediaTarget, item.content);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendDocument error: ${result.error}`);
|
||||
await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
|
||||
}
|
||||
} else if (item.type === "media") {
|
||||
const result = await sendMediaAuto({
|
||||
to: actx.qualifiedTarget,
|
||||
text: "",
|
||||
mediaUrl: item.content,
|
||||
accountId: account.accountId,
|
||||
replyToId: event.messageId,
|
||||
account,
|
||||
});
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
|
||||
await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
const mediaSendCtx: MediaSendContext = {
|
||||
mediaTarget,
|
||||
qualifiedTarget: actx.qualifiedTarget,
|
||||
account,
|
||||
replyToId: event.messageId,
|
||||
log,
|
||||
};
|
||||
|
||||
return { handled: true, normalizedText: text };
|
||||
// 使用 media-send.ts 的统一执行器
|
||||
await executeSendQueue(sendQueue, mediaSendCtx, {
|
||||
onSendText: async (textContent) => {
|
||||
await sendTextChunks(filterInternalMarkers(textContent), event, actx, sendWithRetry, consumeQuoteRef);
|
||||
},
|
||||
});
|
||||
|
||||
return { handled: true, normalizedText: replyText };
|
||||
}
|
||||
|
||||
// ============ 2. 非结构化消息发送(普通文本 + 图片) ============
|
||||
@@ -324,48 +260,6 @@ export async function sendPlainReply(
|
||||
|
||||
// ============ 内部辅助函数 ============
|
||||
|
||||
/** 解码媒体路径:剥离 MEDIA: 前缀、展开 ~、修复转义 */
|
||||
function decodeMediaPath(raw: string, log: DeliverAccountContext["log"], prefix: string): string {
|
||||
let mediaPath = raw;
|
||||
if (mediaPath.startsWith("MEDIA:")) {
|
||||
mediaPath = mediaPath.slice("MEDIA:".length);
|
||||
}
|
||||
mediaPath = normalizePath(mediaPath);
|
||||
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
||||
|
||||
try {
|
||||
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
||||
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
||||
|
||||
if (hasOctal || hasNonASCII) {
|
||||
log?.debug?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`);
|
||||
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
||||
return String.fromCharCode(parseInt(octal, 8));
|
||||
});
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code <= 0xFF) {
|
||||
bytes.push(code);
|
||||
} else {
|
||||
const charBytes = Buffer.from(decoded[i], "utf8");
|
||||
bytes.push(...charBytes);
|
||||
}
|
||||
}
|
||||
const buffer = Buffer.from(bytes);
|
||||
const utf8Decoded = buffer.toString("utf8");
|
||||
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
|
||||
mediaPath = utf8Decoded;
|
||||
log?.debug?.(`${prefix} Successfully decoded path: ${mediaPath}`);
|
||||
}
|
||||
}
|
||||
} catch (decodeErr) {
|
||||
log?.error(`${prefix} Path decode error: ${decodeErr}`);
|
||||
}
|
||||
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
/** 发送文本分块(共用逻辑) */
|
||||
async function sendTextChunks(
|
||||
text: string,
|
||||
@@ -396,30 +290,6 @@ async function sendTextChunks(
|
||||
}
|
||||
}
|
||||
|
||||
/** 语音发送(带 45s 超时保护) */
|
||||
async function sendVoiceWithTimeout(
|
||||
target: MediaTargetContext,
|
||||
voicePath: string,
|
||||
account: ResolvedQQBotAccount,
|
||||
log: DeliverAccountContext["log"],
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
||||
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
||||
const voiceTimeout = 45000;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
|
||||
new Promise<{ channel: string; error: string }>((resolve) =>
|
||||
setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout),
|
||||
),
|
||||
]);
|
||||
if (result.error) log?.error(`${prefix} sendVoice error: ${result.error}`);
|
||||
} catch (err) {
|
||||
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Markdown 模式发送 */
|
||||
async function sendMarkdownReply(
|
||||
textWithoutImages: string,
|
||||
|
||||
211
src/outbound.ts
211
src/outbound.ts
@@ -19,12 +19,11 @@ import {
|
||||
MediaFileType,
|
||||
} from "./api.js";
|
||||
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
||||
import { normalizeMediaTags } from "./utils/media-tags.js";
|
||||
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
||||
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
||||
import { isLocalPath as isLocalFilePath, normalizePath } from "./utils/platform.js";
|
||||
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
||||
import { downloadFile } from "./image-server.js";
|
||||
import { getQQBotMediaDir } from "./utils/platform.js";
|
||||
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
|
||||
|
||||
// ============ 消息回复限流器 ============
|
||||
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
||||
@@ -706,7 +705,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
// 不应该发生,但作为保底
|
||||
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
channel: "qqbot",
|
||||
error: limitCheck.message
|
||||
};
|
||||
}
|
||||
@@ -722,170 +721,66 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
// <qqvideo>路径或URL</qqvideo> — 视频
|
||||
// <qqfile>路径</qqfile> — 文件
|
||||
// <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
|
||||
// 使用 deliver-common.ts 的公共解析器,消除与 gateway.ts 的重复
|
||||
|
||||
// 预处理:纠正小模型常见的标签拼写错误和格式问题
|
||||
text = normalizeMediaTags(text);
|
||||
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(text);
|
||||
|
||||
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
const mediaTagMatches = text.match(mediaTagRegex);
|
||||
|
||||
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
||||
console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
||||
|
||||
// 构建发送队列:根据内容在原文中的实际位置顺序发送
|
||||
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
||||
// 添加标签前的文本
|
||||
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (textBefore) {
|
||||
sendQueue.push({ type: "text", content: textBefore });
|
||||
}
|
||||
|
||||
const tagName = match[1]!.toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
|
||||
|
||||
// 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
|
||||
let mediaPath = match[2]?.trim() ?? "";
|
||||
if (mediaPath.startsWith("MEDIA:")) {
|
||||
mediaPath = mediaPath.slice("MEDIA:".length);
|
||||
}
|
||||
mediaPath = normalizePath(mediaPath);
|
||||
|
||||
// 处理可能被模型转义的路径
|
||||
// 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
|
||||
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
||||
|
||||
// 2. 八进制转义序列 + UTF-8 双重编码修复
|
||||
try {
|
||||
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
||||
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
||||
|
||||
if (hasOctal || hasNonASCII) {
|
||||
console.log(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
||||
|
||||
// Step 1: 将八进制转义转换为字节
|
||||
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
||||
return String.fromCharCode(parseInt(octal, 8));
|
||||
});
|
||||
|
||||
// Step 2: 提取所有字节(包括 Latin-1 字符)
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code <= 0xFF) {
|
||||
bytes.push(code);
|
||||
} else {
|
||||
const charBytes = Buffer.from(decoded[i], 'utf8');
|
||||
bytes.push(...charBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 尝试按 UTF-8 解码
|
||||
const buffer = Buffer.from(bytes);
|
||||
const utf8Decoded = buffer.toString('utf8');
|
||||
|
||||
if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
|
||||
mediaPath = utf8Decoded;
|
||||
console.log(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
||||
}
|
||||
}
|
||||
} catch (decodeErr) {
|
||||
console.error(`[qqbot] sendText: Path decode error: ${decodeErr}`);
|
||||
}
|
||||
|
||||
if (mediaPath) {
|
||||
if (tagName === "qqmedia") {
|
||||
sendQueue.push({ type: "media", content: mediaPath });
|
||||
console.log(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
||||
} else if (tagName === "qqvoice") {
|
||||
sendQueue.push({ type: "voice", content: mediaPath });
|
||||
console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
||||
} else if (tagName === "qqvideo") {
|
||||
sendQueue.push({ type: "video", content: mediaPath });
|
||||
console.log(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
||||
} else if (tagName === "qqfile") {
|
||||
sendQueue.push({ type: "file", content: mediaPath });
|
||||
console.log(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
||||
} else {
|
||||
sendQueue.push({ type: "image", content: mediaPath });
|
||||
console.log(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 添加最后一个标签后的文本
|
||||
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (textAfter) {
|
||||
sendQueue.push({ type: "text", content: textAfter });
|
||||
}
|
||||
|
||||
if (hasMedia && sendQueue.length > 0) {
|
||||
console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
||||
|
||||
// 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
|
||||
// 构建统一的媒体发送上下文
|
||||
const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
|
||||
const mediaSendCtx: MediaSendContext = {
|
||||
mediaTarget,
|
||||
qualifiedTarget: to,
|
||||
account,
|
||||
replyToId: replyToId ?? undefined,
|
||||
log: {
|
||||
info: (msg: string) => console.log(msg),
|
||||
error: (msg: string) => console.error(msg),
|
||||
debug: (msg: string) => console.log(msg),
|
||||
},
|
||||
};
|
||||
|
||||
let lastResult: OutboundResult = { channel: "qqbot" };
|
||||
|
||||
for (const item of sendQueue) {
|
||||
try {
|
||||
if (item.type === "text") {
|
||||
// 发送文本
|
||||
if (replyToId) {
|
||||
const accessToken = await getToken(account);
|
||||
const target = parseTarget(to);
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendC2CMessage(accessToken, target.id, item.content, replyToId);
|
||||
recordMessageReply(replyToId);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendGroupMessage(accessToken, target.id, item.content, replyToId);
|
||||
recordMessageReply(replyToId);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
||||
} else {
|
||||
const result = await sendChannelMessage(accessToken, target.id, item.content, replyToId);
|
||||
recordMessageReply(replyToId);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
}
|
||||
// 使用统一的发送队列执行器
|
||||
await executeSendQueue(sendQueue, mediaSendCtx, {
|
||||
onSendText: async (textContent) => {
|
||||
// sendText 场景的文本发送:需要区分主动/被动消息
|
||||
if (replyToId) {
|
||||
const accessToken = await getToken(account);
|
||||
const target = parseTarget(to);
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendC2CMessage(accessToken, target.id, textContent, replyToId);
|
||||
recordMessageReply(replyToId);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendGroupMessage(accessToken, target.id, textContent, replyToId);
|
||||
recordMessageReply(replyToId);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
||||
} else {
|
||||
const accessToken = await getToken(account);
|
||||
const target = parseTarget(to);
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendProactiveGroupMessage(accessToken, target.id, item.content);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
} else {
|
||||
const result = await sendChannelMessage(accessToken, target.id, item.content);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
}
|
||||
const result = await sendChannelMessage(accessToken, target.id, textContent, replyToId);
|
||||
recordMessageReply(replyToId);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
}
|
||||
} else {
|
||||
const accessToken = await getToken(account);
|
||||
const target = parseTarget(to);
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendProactiveC2CMessage(accessToken, target.id, textContent);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendProactiveGroupMessage(accessToken, target.id, textContent);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
} else {
|
||||
const result = await sendChannelMessage(accessToken, target.id, textContent);
|
||||
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
||||
}
|
||||
console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
||||
} else if (item.type === "image") {
|
||||
lastResult = await sendPhoto(mediaTarget, item.content);
|
||||
} else if (item.type === "voice") {
|
||||
lastResult = await sendVoice(mediaTarget, item.content, undefined, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
|
||||
} else if (item.type === "video") {
|
||||
lastResult = await sendVideoMsg(mediaTarget, item.content);
|
||||
} else if (item.type === "file") {
|
||||
lastResult = await sendDocument(mediaTarget, item.content);
|
||||
} else if (item.type === "media") {
|
||||
// qqmedia: 自动根据扩展名路由
|
||||
lastResult = await sendMedia({
|
||||
to, text: "", mediaUrl: item.content,
|
||||
accountId: account.accountId, replyToId, account,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
console.log(`[qqbot] sendText: Sent text part: ${textContent.slice(0, 30)}...`);
|
||||
},
|
||||
});
|
||||
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
1088
src/streaming.ts
Normal file
1088
src/streaming.ts
Normal file
File diff suppressed because it is too large
Load Diff
80
src/types.ts
80
src/types.ts
@@ -75,6 +75,15 @@ export interface QQBotAccountConfig {
|
||||
* 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
|
||||
*/
|
||||
deliverDebounce?: DeliverDebounceConfig;
|
||||
/**
|
||||
* 是否启用流式消息(默认 false)
|
||||
* 启用后,AI 的回复会以流式形式逐步显示在 QQ 聊天中,
|
||||
* 用户可以看到文字逐字出现的打字机效果。
|
||||
* 设置为 true 可开启流式消息。
|
||||
*
|
||||
* 注意:仅 C2C(私聊)支持流式消息 API。
|
||||
*/
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,3 +221,74 @@ export interface WSPayload {
|
||||
s?: number;
|
||||
t?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---- 流式消息常量 ----
|
||||
|
||||
/** 流式消息输入模式 */
|
||||
export const StreamInputMode = {
|
||||
/** 每次发送的 content_raw 替换整条消息内容 */
|
||||
REPLACE: "replace",
|
||||
} as const;
|
||||
export type StreamInputMode = (typeof StreamInputMode)[keyof typeof StreamInputMode];
|
||||
|
||||
/** 流式消息输入状态 */
|
||||
export const StreamInputState = {
|
||||
/** 正文生成中 */
|
||||
GENERATING: 1,
|
||||
/** 正文生成结束(终结状态) */
|
||||
DONE: 10,
|
||||
} as const;
|
||||
export type StreamInputState = (typeof StreamInputState)[keyof typeof StreamInputState];
|
||||
|
||||
/** 流式消息内容类型 */
|
||||
export const StreamContentType = {
|
||||
MARKDOWN: "markdown",
|
||||
} as const;
|
||||
export type StreamContentType = (typeof StreamContentType)[keyof typeof StreamContentType];
|
||||
|
||||
/**
|
||||
* 流式消息请求体
|
||||
* 对应 StreamReq proto
|
||||
*/
|
||||
export interface StreamMessageRequest {
|
||||
/** 输入模式 */
|
||||
input_mode: StreamInputMode;
|
||||
/** 输入状态 */
|
||||
input_state: StreamInputState;
|
||||
/** 内容类型 */
|
||||
content_type: StreamContentType;
|
||||
/** markdown 内容 */
|
||||
content_raw: string;
|
||||
/** 事件 ID */
|
||||
event_id: string;
|
||||
/** 原始消息 ID */
|
||||
msg_id: string;
|
||||
/** 流式消息 ID,首次发送后返回,后续分片需携带 */
|
||||
stream_msg_id?: string;
|
||||
/** 递增序号 */
|
||||
msg_seq: number;
|
||||
/** 同一条流式会话内的发送索引,从 0 开始,每次发送前递增;新流式会话重新从 0 开始 */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式消息响应体
|
||||
* 对应 StreamRsp proto
|
||||
*
|
||||
* 成功时返回:{ id, timestamp, extInfo }(无 code/message)
|
||||
* 失败时返回:{ code, message }(code > 0)
|
||||
*/
|
||||
export interface StreamMessageResponse {
|
||||
/** 错误码,仅失败时存在(> 0 表示失败);成功时不存在 */
|
||||
code?: number;
|
||||
/** 错误信息,仅失败时存在 */
|
||||
message?: string;
|
||||
/** 流式消息 ID */
|
||||
id?: string;
|
||||
/** 时间戳 */
|
||||
timestamp?: string;
|
||||
/** 扩展信息 */
|
||||
extInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* 用户面向的提示文案 — 已清空
|
||||
*
|
||||
* 设计原则(对齐飞书插件):
|
||||
* QQBot 插件层不生成额外的用户提示信息。
|
||||
* 所有运行时错误仅写日志,不面向用户展示。
|
||||
*/
|
||||
563
src/utils/media-send.ts
Normal file
563
src/utils/media-send.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* 富媒体标签解析与发送队列
|
||||
*
|
||||
* 提供媒体标签(qqimg / qqvoice / qqvideo / qqfile / qqmedia)的检测、
|
||||
* 拆分、路径编码修复,以及统一的发送队列执行器。
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef -- Buffer is a Node.js global */
|
||||
import { normalizeMediaTags } from "./media-tags.js";
|
||||
import { normalizePath } from "./platform.js";
|
||||
import {
|
||||
sendPhoto,
|
||||
sendVoice,
|
||||
sendVideoMsg,
|
||||
sendDocument,
|
||||
sendMedia as sendMediaAuto,
|
||||
type MediaTargetContext,
|
||||
} from "../outbound.js";
|
||||
import type { ResolvedQQBotAccount } from "../types.js";
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
/** 发送队列项 */
|
||||
export interface SendQueueItem {
|
||||
type: "text" | "image" | "voice" | "video" | "file" | "media";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */
|
||||
export const MEDIA_TAG_REGEX =
|
||||
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
|
||||
/** 创建一个新的全局标签正则实例(每次调用 reset lastIndex) */
|
||||
export function createMediaTagRegex(): RegExp {
|
||||
return new RegExp(MEDIA_TAG_REGEX.source, MEDIA_TAG_REGEX.flags);
|
||||
}
|
||||
|
||||
/** 媒体发送上下文(统一的,供流式和普通模式共用) */
|
||||
export interface MediaSendContext {
|
||||
/** 媒体目标上下文(用于 sendPhoto/sendVoice 等) */
|
||||
mediaTarget: MediaTargetContext;
|
||||
/** qualifiedTarget(格式 "qqbot:c2c:xxx" 或 "qqbot:group:xxx",用于 sendMediaAuto) */
|
||||
qualifiedTarget: string;
|
||||
/** 账户配置 */
|
||||
account: ResolvedQQBotAccount;
|
||||
/** 事件消息 ID(用于被动回复) */
|
||||
replyToId?: string;
|
||||
/** 日志 */
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 路径编码修复 ============
|
||||
|
||||
/**
|
||||
* 修复路径编码问题(双反斜杠、八进制转义、UTF-8 双重编码)
|
||||
*
|
||||
* 这是由于 LLM 输出路径时可能引入的编码问题:
|
||||
* - Markdown 转义导致双反斜杠
|
||||
* - 八进制转义序列(来自某些 shell 工具的输出)
|
||||
* - UTF-8 双重编码(中文路径经过多层处理后的乱码)
|
||||
*
|
||||
* 此方法在 gateway.ts deliver 回调、outbound.ts sendText、
|
||||
* streaming.ts sendMediaQueue 中共用。
|
||||
*/
|
||||
export function fixPathEncoding(mediaPath: string, log?: { debug?: (msg: string) => void; error?: (msg: string) => void }): string {
|
||||
// 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
|
||||
let result = mediaPath.replace(/\\\\/g, "\\");
|
||||
|
||||
// 2. 八进制转义序列 + UTF-8 双重编码修复
|
||||
try {
|
||||
const hasOctal = /\\[0-7]{1,3}/.test(result);
|
||||
const hasNonASCII = /[\u0080-\u00FF]/.test(result);
|
||||
|
||||
if (hasOctal || hasNonASCII) {
|
||||
log?.debug?.(`Decoding path with mixed encoding: ${result}`);
|
||||
|
||||
// Step 1: 将八进制转义转换为字节
|
||||
let decoded = result.replace(
|
||||
/\\([0-7]{1,3})/g,
|
||||
(_: string, octal: string) => String.fromCharCode(parseInt(octal, 8)),
|
||||
);
|
||||
|
||||
// Step 2: 提取所有字节(包括 Latin-1 字符)
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code <= 0xff) {
|
||||
bytes.push(code);
|
||||
} else {
|
||||
const charBytes = Buffer.from(decoded[i]!, "utf8");
|
||||
bytes.push(...charBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 尝试按 UTF-8 解码
|
||||
const buffer = Buffer.from(bytes);
|
||||
const utf8Decoded = buffer.toString("utf8");
|
||||
|
||||
if (
|
||||
!utf8Decoded.includes("\uFFFD") ||
|
||||
utf8Decoded.length < decoded.length
|
||||
) {
|
||||
result = utf8Decoded;
|
||||
log?.debug?.(`Successfully decoded path: ${result}`);
|
||||
}
|
||||
}
|
||||
} catch (decodeErr) {
|
||||
log?.error?.(`Path decode error: ${decodeErr}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ 媒体标签解析 ============
|
||||
|
||||
/**
|
||||
* 检测文本是否包含富媒体标签
|
||||
*/
|
||||
export function hasMediaTags(text: string): boolean {
|
||||
const normalized = normalizeMediaTags(text);
|
||||
const regex = createMediaTagRegex();
|
||||
return regex.test(normalized);
|
||||
}
|
||||
|
||||
/** findFirstClosedMediaTag 的返回值 */
|
||||
export interface FirstClosedMediaTag {
|
||||
/** 标签前的纯文本(已 trim) */
|
||||
textBefore: string;
|
||||
/** 标签类型(小写,如 "qqvoice") */
|
||||
tagName: string;
|
||||
/** 标签内的媒体路径(已 trim、去 MEDIA: 前缀、修复编码) */
|
||||
mediaPath: string;
|
||||
/** 标签在输入文本中的结束索引(紧接标签后的第一个字符位置) */
|
||||
tagEndIndex: number;
|
||||
/** 映射后的发送队列项类型 */
|
||||
itemType: SendQueueItem["type"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文本中查找**第一个**完整闭合的媒体标签
|
||||
*
|
||||
* 与 splitByMediaTags 不同,此函数只匹配一个标签就停止,
|
||||
* 用于流式场景的"循环消费"模式:每次处理一个标签,更新偏移,再找下一个。
|
||||
*
|
||||
* @param text 待检查的文本(应已 normalize 过)
|
||||
* @returns 第一个闭合标签的信息,没有则返回 null
|
||||
*/
|
||||
export function findFirstClosedMediaTag(
|
||||
text: string,
|
||||
log?: { info?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void },
|
||||
): FirstClosedMediaTag | null {
|
||||
const regex = createMediaTagRegex();
|
||||
const match = regex.exec(text);
|
||||
if (!match) return null;
|
||||
|
||||
const textBefore = text.slice(0, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
||||
const tagName = match[1]!.toLowerCase();
|
||||
let mediaPath = match[2]?.trim() ?? "";
|
||||
|
||||
// 剥离 MEDIA: 前缀
|
||||
if (mediaPath.startsWith("MEDIA:")) {
|
||||
mediaPath = mediaPath.slice("MEDIA:".length);
|
||||
}
|
||||
mediaPath = normalizePath(mediaPath);
|
||||
mediaPath = fixPathEncoding(mediaPath, log);
|
||||
|
||||
const typeMap: Record<string, SendQueueItem["type"]> = {
|
||||
qqimg: "image",
|
||||
qqvoice: "voice",
|
||||
qqvideo: "video",
|
||||
qqfile: "file",
|
||||
qqmedia: "media",
|
||||
};
|
||||
|
||||
return {
|
||||
textBefore,
|
||||
tagName,
|
||||
mediaPath,
|
||||
tagEndIndex: match.index! + match[0].length,
|
||||
itemType: typeMap[tagName] ?? "image",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体标签拆分结果
|
||||
*/
|
||||
export interface MediaSplitResult {
|
||||
/** 是否包含媒体标签 */
|
||||
hasMediaTags: boolean;
|
||||
/** 媒体标签前的纯文本 */
|
||||
textBeforeFirstTag: string;
|
||||
/** 媒体标签后的剩余文本 */
|
||||
textAfterLastTag: string;
|
||||
/** 完整的发送队列(标签间的文本 + 媒体项) */
|
||||
mediaQueue: SendQueueItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本按富媒体标签拆分为三部分
|
||||
*
|
||||
* 用于两个场景:
|
||||
* 1. 流式模式:中断-恢复流程(标签前文本 → 结束流式 → 发送媒体 → 新流式 → 标签后文本)
|
||||
* 2. 普通模式:构建按顺序发送的队列
|
||||
*/
|
||||
export function splitByMediaTags(
|
||||
text: string,
|
||||
log?: { info?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void },
|
||||
): MediaSplitResult {
|
||||
const normalized = normalizeMediaTags(text);
|
||||
const regex = createMediaTagRegex();
|
||||
const matches = [...normalized.matchAll(regex)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
hasMediaTags: false,
|
||||
textBeforeFirstTag: normalized,
|
||||
textAfterLastTag: "",
|
||||
mediaQueue: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 第一个标签前的纯文本
|
||||
const firstMatch = matches[0]!;
|
||||
const textBeforeFirstTag = normalized
|
||||
.slice(0, firstMatch.index)
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
// 最后一个标签后的纯文本
|
||||
const lastMatch = matches[matches.length - 1]!;
|
||||
const lastMatchEnd = lastMatch.index! + lastMatch[0].length;
|
||||
const textAfterLastTag = normalized
|
||||
.slice(lastMatchEnd)
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
// 构建媒体发送队列
|
||||
const mediaQueue: SendQueueItem[] = [];
|
||||
let lastIndex = firstMatch.index!;
|
||||
|
||||
for (const match of matches) {
|
||||
// 标签前的文本(标签之间的间隔文本)
|
||||
const textBetween = normalized
|
||||
.slice(lastIndex, match.index)
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
if (textBetween && lastIndex !== firstMatch.index) {
|
||||
// 只添加非首段的间隔文本(首段由 textBeforeFirstTag 覆盖)
|
||||
mediaQueue.push({ type: "text", content: textBetween });
|
||||
}
|
||||
|
||||
// 解析标签内容
|
||||
const tagName = match[1]!.toLowerCase();
|
||||
let mediaPath = match[2]?.trim() ?? "";
|
||||
|
||||
// 剥离 MEDIA: 前缀
|
||||
if (mediaPath.startsWith("MEDIA:")) {
|
||||
mediaPath = mediaPath.slice("MEDIA:".length);
|
||||
}
|
||||
mediaPath = normalizePath(mediaPath);
|
||||
|
||||
// 修复路径编码问题
|
||||
mediaPath = fixPathEncoding(mediaPath, log);
|
||||
|
||||
// 根据标签类型加入队列
|
||||
const typeMap: Record<string, SendQueueItem["type"]> = {
|
||||
qqimg: "image",
|
||||
qqvoice: "voice",
|
||||
qqvideo: "video",
|
||||
qqfile: "file",
|
||||
qqmedia: "media",
|
||||
};
|
||||
const itemType = typeMap[tagName] ?? "image";
|
||||
if (mediaPath) {
|
||||
mediaQueue.push({ type: itemType, content: mediaPath });
|
||||
log?.info?.(`Found ${itemType} in <${tagName}>: ${mediaPath.slice(0, 80)}`);
|
||||
}
|
||||
|
||||
lastIndex = match.index! + match[0].length;
|
||||
}
|
||||
|
||||
return {
|
||||
hasMediaTags: true,
|
||||
textBeforeFirstTag,
|
||||
textAfterLastTag,
|
||||
mediaQueue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本中解析出完整的发送队列(含标签前后的纯文本)
|
||||
*
|
||||
* 与 splitByMediaTags 的区别:
|
||||
* - splitByMediaTags 分为 before / queue / after 三段(供流式模式的中断-恢复)
|
||||
* - parseMediaTagsToSendQueue 返回一个扁平的完整队列(供普通模式按顺序发送)
|
||||
*
|
||||
* 适用于 gateway.ts deliver 回调和 outbound.ts sendText。
|
||||
*/
|
||||
export function parseMediaTagsToSendQueue(
|
||||
text: string,
|
||||
log?: { info?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void },
|
||||
): { hasMediaTags: boolean; sendQueue: SendQueueItem[] } {
|
||||
const split = splitByMediaTags(text, log);
|
||||
|
||||
if (!split.hasMediaTags) {
|
||||
return { hasMediaTags: false, sendQueue: [] };
|
||||
}
|
||||
|
||||
const sendQueue: SendQueueItem[] = [];
|
||||
|
||||
// 标签前的文本
|
||||
if (split.textBeforeFirstTag) {
|
||||
sendQueue.push({ type: "text", content: split.textBeforeFirstTag });
|
||||
}
|
||||
|
||||
// 媒体队列(含标签间文本)
|
||||
sendQueue.push(...split.mediaQueue);
|
||||
|
||||
// 标签后的文本
|
||||
if (split.textAfterLastTag) {
|
||||
sendQueue.push({ type: "text", content: split.textAfterLastTag });
|
||||
}
|
||||
|
||||
return { hasMediaTags: true, sendQueue };
|
||||
}
|
||||
|
||||
// ============ 发送队列执行 ============
|
||||
|
||||
/**
|
||||
* 统一执行发送队列
|
||||
*
|
||||
* 遍历 sendQueue,按类型调用对应的发送函数。
|
||||
* 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
|
||||
*/
|
||||
export async function executeSendQueue(
|
||||
queue: SendQueueItem[],
|
||||
ctx: MediaSendContext,
|
||||
options: {
|
||||
/** 文本发送回调(每种场景的文本发送方式不同) */
|
||||
onSendText?: (text: string) => Promise<void>;
|
||||
/** 是否跳过 inter-tag 文本(流式模式下通常跳过,由新流式会话处理) */
|
||||
skipInterTagText?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
|
||||
const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
|
||||
|
||||
for (const item of queue) {
|
||||
try {
|
||||
if (item.type === "text") {
|
||||
if (options.skipInterTagText) {
|
||||
log?.info(`${prefix} executeSendQueue: skipping inter-tag text (${item.content.length} chars)`);
|
||||
continue;
|
||||
}
|
||||
if (options.onSendText) {
|
||||
await options.onSendText(item.content);
|
||||
} else {
|
||||
log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
|
||||
|
||||
if (item.type === "image") {
|
||||
const result = await sendPhoto(mediaTarget, item.content);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendPhoto error: ${result.error}`);
|
||||
}
|
||||
} else if (item.type === "voice") {
|
||||
const uploadFormats =
|
||||
account.config?.audioFormatPolicy?.uploadDirectFormats ??
|
||||
account.config?.voiceDirectUploadFormats;
|
||||
const transcodeEnabled =
|
||||
account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
||||
const voiceTimeout = 45000; // 45s
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
|
||||
new Promise<{ channel: string; error: string }>((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }),
|
||||
voiceTimeout,
|
||||
),
|
||||
),
|
||||
]);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendVoice error: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
|
||||
}
|
||||
} else if (item.type === "video") {
|
||||
const result = await sendVideoMsg(mediaTarget, item.content);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
|
||||
}
|
||||
} else if (item.type === "file") {
|
||||
const result = await sendDocument(mediaTarget, item.content);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendDocument error: ${result.error}`);
|
||||
}
|
||||
} else if (item.type === "media") {
|
||||
const result = await sendMediaAuto({
|
||||
to: qualifiedTarget,
|
||||
text: "",
|
||||
mediaUrl: item.content,
|
||||
accountId: account.accountId,
|
||||
replyToId,
|
||||
account,
|
||||
});
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本中剥离所有媒体标签(用于最终显示)
|
||||
*/
|
||||
export function stripMediaTags(text: string): string {
|
||||
const regex = createMediaTagRegex();
|
||||
return text.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。
|
||||
*
|
||||
* 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。
|
||||
* 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。
|
||||
*
|
||||
* 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。
|
||||
*/
|
||||
export function stripIncompleteMediaTag(text: string): [safeText: string, hasIncomplete: boolean] {
|
||||
if (!text) return [text, false];
|
||||
|
||||
const lastNL = text.lastIndexOf("\n");
|
||||
const lastLine = lastNL === -1 ? text : text.slice(lastNL + 1);
|
||||
if (!lastLine) return [text, false]; // 以换行结尾,安全
|
||||
|
||||
const lineStart = lastNL === -1 ? 0 : lastNL + 1;
|
||||
|
||||
// ---- 媒体标签名判断 ----
|
||||
const MEDIA_NAMES = [
|
||||
"qq", "img", "image", "pic", "photo", "voice", "audio", "video",
|
||||
"file", "doc", "media", "attach", "send", "document", "picture",
|
||||
"qqvoice", "qqaudio", "qqvideo", "qqimg", "qqimage", "qqfile",
|
||||
"qqpic", "qqphoto", "qqmedia", "qqattach", "qqsend", "qqdocument", "qqpicture",
|
||||
];
|
||||
const isMedia = (n: string) => MEDIA_NAMES.includes(n.toLowerCase());
|
||||
const couldBeMedia = (n: string) => { const l = n.toLowerCase(); return MEDIA_NAMES.some(m => m.startsWith(l)); };
|
||||
|
||||
/** 截断到 lastLine 中位置 pos 之前,返回 [safe, true] */
|
||||
const cutAt = (pos: number): [string, true] => [
|
||||
text.slice(0, lineStart + pos).trimEnd(),
|
||||
true,
|
||||
];
|
||||
|
||||
/** 检查 lastLine 中位置 pos 处的媒体开标签后面是否有完整闭合标签 */
|
||||
const hasClosingAfter = (pos: number, name: string): boolean => {
|
||||
const rest = lastLine.slice(pos + 1); // < 之后
|
||||
const gt = rest.search(/[>>]/);
|
||||
if (gt < 0) return false;
|
||||
const after = rest.slice(gt + 1);
|
||||
return new RegExp(`[<\uFF1C]/${name}\\s*[>\uFF1E]`, "i").test(after);
|
||||
};
|
||||
|
||||
// ---- 回溯状态 ----
|
||||
// 遇到不完整的闭合标签/孤立 < 时,记录并继续往左找对应的开标签
|
||||
let searchTag: string | null = null; // 要找的开标签名,"*" = 来自孤立 <
|
||||
let searchIsClosing = false; // 触发回溯的是闭合类(</、</tag)还是开类(<)
|
||||
let fallbackPos = -1; // 最右边触发回溯的 < 的位置
|
||||
|
||||
for (let i = lastLine.length - 1; i >= 0; i--) {
|
||||
const ch = lastLine[i];
|
||||
if (ch !== "<" && ch !== "\uFF1C") continue;
|
||||
|
||||
const after = lastLine.slice(i + 1);
|
||||
const isClosing = after.startsWith("/");
|
||||
const nameStr = isClosing ? after.slice(1) : after;
|
||||
const nameMatch = nameStr.match(/^(\w+)/);
|
||||
|
||||
// ======== 回溯模式:正在找对应的开标签 ========
|
||||
if (searchTag) {
|
||||
if (!nameMatch || isClosing) continue;
|
||||
const cand = nameMatch[1].toLowerCase();
|
||||
if (!isMedia(cand)) continue;
|
||||
// 跳过已有完整闭合对的开标签
|
||||
if (hasClosingAfter(i, cand)) continue;
|
||||
|
||||
if (searchTag === "*") {
|
||||
return cutAt(i); // 通配:任何未闭合的媒体开标签都匹配
|
||||
}
|
||||
// 精确/前缀匹配(闭合标签名可能不完整,如 </qq 对 <qqvoice)
|
||||
const t = searchTag.toLowerCase();
|
||||
if (cand === t || cand.startsWith(t)) return cutAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ======== 正常扫描 ========
|
||||
|
||||
// --- 无标签名:孤立 < 或 </ ---
|
||||
if (!nameMatch) {
|
||||
if (!after) {
|
||||
// 孤立 <:可能是新开标签,往左找未闭合的媒体开标签
|
||||
if (fallbackPos < 0) fallbackPos = i;
|
||||
searchTag = "*"; searchIsClosing = false;
|
||||
} else if (after === "/") {
|
||||
// 孤立 </:闭合标签开始,找不到开标签时原样返回
|
||||
if (fallbackPos < 0) fallbackPos = i;
|
||||
searchTag = "*"; searchIsClosing = true;
|
||||
}
|
||||
// 其他(如 "< 3"):非标签,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
const tag = nameMatch[1];
|
||||
const restAfterName = nameStr.slice(tag.length);
|
||||
const hasGT = /[>>]/.test(restAfterName);
|
||||
|
||||
// --- 不是媒体标签(也不是前缀) ---
|
||||
if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT)) continue;
|
||||
|
||||
// --- 标签未闭合(无 >),还在输入中 ---
|
||||
if (!hasGT) {
|
||||
if (isClosing) {
|
||||
// 不完整闭合标签(如 </voice、</i)→ 回溯找开标签
|
||||
if (fallbackPos < 0) fallbackPos = i;
|
||||
searchTag = tag; searchIsClosing = true;
|
||||
continue;
|
||||
}
|
||||
// 不完整开标签(如 <img、<i)→ 截断
|
||||
return cutAt(i);
|
||||
}
|
||||
|
||||
// --- 标签有 >,是完整的 ---
|
||||
if (isClosing) return [text, false]; // 完整闭合标签 </tag> → 安全
|
||||
|
||||
// 完整开标签 <tag...>,检查后面有无对应 </tag>
|
||||
if (hasClosingAfter(i, tag)) return [text, false];
|
||||
return cutAt(i); // 无闭合 → 截断
|
||||
}
|
||||
|
||||
// ---- 循环结束,处理回溯未命中 ----
|
||||
if (searchTag) {
|
||||
if (!searchIsClosing) {
|
||||
// 来自孤立 <,前面没有媒体开标签 → 截断到那个 < 前面
|
||||
return cutAt(fallbackPos);
|
||||
}
|
||||
// 来自闭合类(</、</tag),前面找不到对应开标签 → 不可能是有效媒体标签,原样返回
|
||||
return [text, true];
|
||||
}
|
||||
|
||||
return [text, false]; // 最后一行无任何 < → 安全
|
||||
}
|
||||
770
tests/streaming-controller.test.ts
Normal file
770
tests/streaming-controller.test.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* StreamingController 集成测试
|
||||
*
|
||||
* 通过 mock global.fetch 验证流式消息控制器的核心行为,
|
||||
* 重点覆盖:循环消费模型 (processMediaTags) + pendingNormalizedFull 补救机制。
|
||||
*
|
||||
* 运行方式: npx tsx tests/streaming-controller.test.ts
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
|
||||
// ============ Mock global.fetch ============
|
||||
|
||||
/** 记录所有流式 API 调用 */
|
||||
interface StreamCall {
|
||||
content: string;
|
||||
inputState: number; // 1 = GENERATING, 10 = DONE
|
||||
streamMsgId?: string;
|
||||
index: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 记录所有媒体上传 API 调用 */
|
||||
interface MediaUploadCall {
|
||||
url: string;
|
||||
body: any;
|
||||
}
|
||||
|
||||
let streamCalls: StreamCall[] = [];
|
||||
let mediaUploadCalls: MediaUploadCall[] = [];
|
||||
let streamMsgIdCounter = 0;
|
||||
|
||||
/** 控制流式 API 的延迟(毫秒)。设为 > 0 模拟 API 慢响应。 */
|
||||
let apiDelayMs = 0;
|
||||
|
||||
/** 控制媒体上传 API 的延迟(毫秒)。 */
|
||||
let mediaApiDelayMs = 0;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function resetMocks() {
|
||||
streamCalls = [];
|
||||
mediaUploadCalls = [];
|
||||
streamMsgIdCounter = 0;
|
||||
apiDelayMs = 0;
|
||||
mediaApiDelayMs = 0;
|
||||
}
|
||||
|
||||
// 保存原始 fetch
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// 覆写 global.fetch
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
|
||||
// ---- Token 请求 ----
|
||||
if (url.includes("/getAppAccessToken")) {
|
||||
return new Response(JSON.stringify({
|
||||
access_token: "mock-token-12345",
|
||||
expires_in: "7200",
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// ---- 流式消息 API ----
|
||||
if (url.includes("/stream_messages")) {
|
||||
if (apiDelayMs > 0) await sleep(apiDelayMs);
|
||||
|
||||
const call: StreamCall = {
|
||||
content: body.content_raw ?? "",
|
||||
inputState: body.input_state ?? 0,
|
||||
streamMsgId: body.stream_msg_id,
|
||||
index: body.index ?? 0,
|
||||
url,
|
||||
};
|
||||
streamCalls.push(call);
|
||||
|
||||
// 首次调用(无 stream_msg_id)→ 返回新的 stream_msg_id
|
||||
let respBody: any;
|
||||
if (!body.stream_msg_id) {
|
||||
streamMsgIdCounter++;
|
||||
respBody = { id: `stream-${streamMsgIdCounter}`, timestamp: Date.now().toString() };
|
||||
} else {
|
||||
respBody = { id: body.stream_msg_id, timestamp: Date.now().toString() };
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(respBody), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 富媒体上传 API (v2/users/.../files) ----
|
||||
if (url.includes("/files")) {
|
||||
if (mediaApiDelayMs > 0) await sleep(mediaApiDelayMs);
|
||||
|
||||
mediaUploadCalls.push({ url, body });
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
file_uuid: `uuid-${mediaUploadCalls.length}`,
|
||||
file_info: "mock",
|
||||
ttl: 3600,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 普通消息 API (v2/users/.../messages) ----
|
||||
if (url.includes("/messages")) {
|
||||
if (mediaApiDelayMs > 0) await sleep(mediaApiDelayMs);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
id: `msg-resp-${Date.now()}`,
|
||||
timestamp: Date.now().toString(),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// 未匹配的请求,回退到原始 fetch
|
||||
console.warn(`[mock-fetch] 未匹配的请求: ${url}`);
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
// ---- 现在 import StreamingController(它会使用被 mock 的 global.fetch) ----
|
||||
const { StreamingController } = await import("../src/streaming.js");
|
||||
type StreamingControllerType = InstanceType<typeof StreamingController>;
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
/** 等待异步任务完成 */
|
||||
async function flush(ms = 100): Promise<void> {
|
||||
await sleep(ms);
|
||||
}
|
||||
|
||||
/** 收集日志 */
|
||||
const logs: string[] = [];
|
||||
|
||||
function createController(opts?: { mediaContext?: boolean; onReplyBoundary?: (newText: string) => void | Promise<void> }): StreamingControllerType {
|
||||
logs.length = 0;
|
||||
const deps: any = {
|
||||
account: {
|
||||
accountId: "test",
|
||||
enabled: true,
|
||||
appId: "test-app",
|
||||
clientSecret: "test-secret",
|
||||
secretSource: "config" as const,
|
||||
markdownSupport: true,
|
||||
config: {
|
||||
streaming: true,
|
||||
streamingConfig: { throttleMs: 50 }, // 短节流方便测试
|
||||
},
|
||||
},
|
||||
userId: "user-1",
|
||||
replyToMsgId: "msg-1",
|
||||
eventId: "event-1",
|
||||
logPrefix: "[test]",
|
||||
log: {
|
||||
info: (m: string) => logs.push(`[INFO] ${m}`),
|
||||
error: (m: string) => logs.push(`[ERROR] ${m}`),
|
||||
warn: (m: string) => logs.push(`[WARN] ${m}`),
|
||||
debug: (m: string) => logs.push(`[DEBUG] ${m}`),
|
||||
},
|
||||
};
|
||||
if (opts?.mediaContext) {
|
||||
deps.mediaContext = {
|
||||
account: deps.account,
|
||||
event: { type: "c2c", senderId: "user-1", messageId: "msg-1" },
|
||||
log: deps.log,
|
||||
};
|
||||
}
|
||||
if (opts?.onReplyBoundary) {
|
||||
deps.onReplyBoundary = opts.onReplyBoundary;
|
||||
}
|
||||
return new StreamingController(deps);
|
||||
}
|
||||
|
||||
// ============ 测试框架 ============
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failedTests: string[] = [];
|
||||
|
||||
async function test(name: string, fn: () => Promise<void>) {
|
||||
resetMocks();
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✅ ${name}`);
|
||||
passed++;
|
||||
} catch (e: any) {
|
||||
console.log(` ❌ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
if (e.stack) {
|
||||
const lines = (e.stack as string).split("\n").slice(1, 4);
|
||||
for (const l of lines) console.log(` ${l.trim()}`);
|
||||
}
|
||||
// 打印关键日志(去掉 DEBUG 减少噪音)
|
||||
const relevantLogs = logs.filter((l) => !l.includes("[DEBUG]")).slice(-10);
|
||||
if (relevantLogs.length > 0) {
|
||||
console.log(` --- 日志 ---`);
|
||||
for (const l of relevantLogs) console.log(` ${l}`);
|
||||
}
|
||||
failed++;
|
||||
failedTests.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 测试用例 ============
|
||||
|
||||
console.log("\n=== 1. 纯文本流式 ===");
|
||||
|
||||
await test("纯文本: 基本流式 → 完成", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
await ctrl.onPartialReply({ text: "你好" });
|
||||
await flush();
|
||||
await ctrl.onPartialReply({ text: "你好世界" });
|
||||
await flush();
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
|
||||
// 应该有流式分片发送
|
||||
assert.ok(streamCalls.length >= 2, `应至少有 2 次流式调用,实际 ${streamCalls.length}`);
|
||||
// 最后一次应该是 DONE (inputState=10)
|
||||
const last = streamCalls[streamCalls.length - 1];
|
||||
assert.strictEqual(last.inputState, 10, "最后一次应为 DONE");
|
||||
assert.ok(last.content.includes("你好世界"), `最终文本应包含完整内容,实际: "${last.content}"`);
|
||||
// 不应有媒体上传
|
||||
assert.strictEqual(mediaUploadCalls.length, 0, "不应有媒体上传");
|
||||
});
|
||||
|
||||
await test("纯文本: 空文本被忽略", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
await ctrl.onPartialReply({ text: "" });
|
||||
await ctrl.onPartialReply({ text: undefined });
|
||||
await flush();
|
||||
|
||||
assert.strictEqual(streamCalls.length, 0, "不应有流式调用");
|
||||
});
|
||||
|
||||
await test("纯文本: 全空白不启动流式,后续非空白一起发送", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
// 先来一段全空白内容 — 不应启动流式
|
||||
await ctrl.onPartialReply({ text: "\n\n " });
|
||||
await flush();
|
||||
assert.strictEqual(streamCalls.length, 0, "全空白阶段不应有流式调用");
|
||||
|
||||
// 后续有非空白内容到达 — 应启动流式,且包含之前的空白
|
||||
await ctrl.onPartialReply({ text: "\n\n hello world" });
|
||||
await flush(200);
|
||||
|
||||
assert.ok(streamCalls.length >= 1, `应有至少 1 次流式调用,实际 ${streamCalls.length}`);
|
||||
// 首次发送的内容应包含之前保留的空白 + 新内容
|
||||
const firstContent = streamCalls[0].content;
|
||||
assert.ok(firstContent.includes("hello world"), `首次发送应包含 "hello world",实际: "${firstContent}"`);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(100);
|
||||
});
|
||||
|
||||
await test("纯文本: 全空白 + 媒体标签,空白不发送,媒体正常处理", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
apiDelayMs = 5;
|
||||
mediaApiDelayMs = 5;
|
||||
|
||||
// 全空白前缀 + 媒体标签一起到达
|
||||
await ctrl.onPartialReply({ text: "\n\n<qqimg>/tmp/pic.jpg</qqimg>描述文字" });
|
||||
await flush(400);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(200);
|
||||
|
||||
// 验证:日志中检测到了媒体标签
|
||||
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
|
||||
assert.ok(foundLogs.length >= 1, `应检测到 qqimg 标签,实际 ${foundLogs.length} 条`);
|
||||
|
||||
// 验证:不应有 PREFIX MISMATCH 错误
|
||||
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
|
||||
assert.strictEqual(prefixMismatchLogs.length, 0, `不应有 PREFIX MISMATCH,实际: ${prefixMismatchLogs.join("; ")}`);
|
||||
|
||||
// 验证:如果有流式分片发送,内容不应是纯空白
|
||||
const generatingCalls = streamCalls.filter((c) => c.inputState === 1);
|
||||
for (const call of generatingCalls) {
|
||||
assert.ok(call.content.trim().length > 0, `流式分片不应为纯空白: "${call.content}"`);
|
||||
}
|
||||
});
|
||||
|
||||
await test("纯文本: 空白→媒体→空白→媒体→空白,只有媒体发出,空白全忽略", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
apiDelayMs = 5;
|
||||
mediaApiDelayMs = 5;
|
||||
|
||||
// 逐步送入:空白 → 媒体标签1 → 空白 → 媒体标签2 → 空白
|
||||
const fullText =
|
||||
"\n \n" +
|
||||
"<qqimg>/tmp/pic1.jpg</qqimg>" +
|
||||
"\n\n \n" +
|
||||
"<qqimg>/tmp/pic2.jpg</qqimg>" +
|
||||
" \n\n";
|
||||
|
||||
// 模拟流式分段到达
|
||||
// 阶段 1:纯空白
|
||||
await ctrl.onPartialReply({ text: "\n \n" });
|
||||
await flush(200);
|
||||
assert.strictEqual(streamCalls.length, 0, "纯空白阶段不应有流式调用");
|
||||
|
||||
// 阶段 2:空白 + 第一个媒体标签
|
||||
await ctrl.onPartialReply({ text: "\n \n<qqimg>/tmp/pic1.jpg</qqimg>" });
|
||||
await flush(400);
|
||||
|
||||
// 阶段 3:继续加空白
|
||||
await ctrl.onPartialReply({
|
||||
text: "\n \n<qqimg>/tmp/pic1.jpg</qqimg>\n\n \n",
|
||||
});
|
||||
await flush(200);
|
||||
|
||||
// 阶段 4:第二个媒体标签
|
||||
await ctrl.onPartialReply({
|
||||
text: "\n \n<qqimg>/tmp/pic1.jpg</qqimg>\n\n \n<qqimg>/tmp/pic2.jpg</qqimg>",
|
||||
});
|
||||
await flush(400);
|
||||
|
||||
// 阶段 5:末尾空白,完成
|
||||
await ctrl.onPartialReply({ text: fullText });
|
||||
await flush(200);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(300);
|
||||
|
||||
// 验证 1:应检测到 2 个媒体标签
|
||||
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
|
||||
assert.ok(foundLogs.length >= 2, `应检测到至少 2 个 qqimg 标签,实际 ${foundLogs.length} 条`);
|
||||
|
||||
// 验证 2:应有 2 次媒体发送尝试(sendPhoto 可能因文件不存在失败,但 sending 日志应存在)
|
||||
const sendingLogs = logs.filter((l) => l.includes("sending image"));
|
||||
assert.ok(sendingLogs.length >= 2, `应有至少 2 次发送图片尝试,实际 ${sendingLogs.length} 次`);
|
||||
|
||||
// 验证 3:不应有 PREFIX MISMATCH 错误
|
||||
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
|
||||
assert.strictEqual(
|
||||
prefixMismatchLogs.length,
|
||||
0,
|
||||
`不应有 PREFIX MISMATCH,实际: ${prefixMismatchLogs.join("; ")}`,
|
||||
);
|
||||
|
||||
// 验证 4:流式分片(GENERATING 状态)中不应出现纯空白内容
|
||||
const generatingCallsInner = streamCalls.filter((c) => c.inputState === 1);
|
||||
for (const call of generatingCallsInner) {
|
||||
assert.ok(
|
||||
call.content.trim().length > 0,
|
||||
`流式分片不应为纯空白: "${call.content.replace(/\n/g, "\\n").replace(/ /g, "·")}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 5:如果有流式启动(首次调用,无 stream_msg_id),首次内容也不应纯空白
|
||||
const startCalls = streamCalls.filter((c) => !c.streamMsgId);
|
||||
for (const call of startCalls) {
|
||||
assert.ok(
|
||||
call.content.trim().length > 0,
|
||||
`流式启动内容不应为纯空白: "${call.content.replace(/\n/g, "\\n").replace(/ /g, "·")}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== 2. 单个媒体标签 ===");
|
||||
|
||||
await test("媒体标签: 多媒体后跟文本,onIdle 终结不 PREFIX MISMATCH", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
apiDelayMs = 5;
|
||||
mediaApiDelayMs = 5;
|
||||
|
||||
// 模拟实际场景:两个语音标签 + 后续文本描述,逐步到达
|
||||
// 阶段1:第一个语音标签
|
||||
await ctrl.onPartialReply({
|
||||
text: "<qqvoice>/tmp/voice1.mp3</qqvoice>",
|
||||
});
|
||||
await flush(400);
|
||||
|
||||
// 阶段2:两个语音标签
|
||||
await ctrl.onPartialReply({
|
||||
text: "<qqvoice>/tmp/voice1.mp3</qqvoice>\n<qqvoice>/tmp/voice2.mp3</qqvoice>",
|
||||
});
|
||||
await flush(400);
|
||||
|
||||
// 阶段3:两个语音标签 + 后续文本
|
||||
await ctrl.onPartialReply({
|
||||
text: "<qqvoice>/tmp/voice1.mp3</qqvoice>\n<qqvoice>/tmp/voice2.mp3</qqvoice>\n两条语音都发给你啦!",
|
||||
});
|
||||
await flush(400);
|
||||
|
||||
// 完成
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(300);
|
||||
|
||||
// 验证:应检测到 2 个媒体标签
|
||||
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
|
||||
assert.ok(foundLogs.length >= 2, `应检测到至少 2 个标签,实际 ${foundLogs.length} 条`);
|
||||
|
||||
// 核心验证:不应有 PREFIX MISMATCH
|
||||
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
|
||||
assert.strictEqual(
|
||||
prefixMismatchLogs.length,
|
||||
0,
|
||||
`不应有 PREFIX MISMATCH,实际: ${prefixMismatchLogs.join("; ")}`,
|
||||
);
|
||||
|
||||
// 验证:最终流式文本(如果有)应包含后续描述文本
|
||||
const doneCalls = streamCalls.filter((c) => c.inputState === 10);
|
||||
if (doneCalls.length > 0) {
|
||||
const lastDone = doneCalls[doneCalls.length - 1];
|
||||
assert.ok(
|
||||
lastDone.content.includes("两条语音都发给你啦"),
|
||||
`终结分片应包含后续文本,实际: "${lastDone.content.slice(0, 80)}"`,
|
||||
);
|
||||
// 终结文本不应包含原始媒体标签
|
||||
assert.ok(
|
||||
!lastDone.content.includes("<qqvoice>"),
|
||||
`终结文本不应包含 <qqvoice> 标签,实际: "${lastDone.content.slice(0, 80)}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await test("媒体标签: 文本 + 图片 + 后续文本", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
// 一次性送入完整的含图片标签文本
|
||||
await ctrl.onPartialReply({ text: "看图:<qqimg>/tmp/cat.jpg</qqimg>" });
|
||||
await flush(300);
|
||||
|
||||
// 后续文本到达
|
||||
await ctrl.onPartialReply({ text: "看图:<qqimg>/tmp/cat.jpg</qqimg>\n\n好看吧?" });
|
||||
await flush(300);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
|
||||
// 验证:应有媒体上传调用(图片需要先上传再发送)
|
||||
// 或者至少流式中出现过图片相关处理
|
||||
// 关键验证:最终流式文本不应包含原始 <qqimg> 标签
|
||||
const lastStream = streamCalls[streamCalls.length - 1];
|
||||
assert.ok(!lastStream.content.includes("<qqimg>"), "最终文本不应包含 <qqimg> 标签");
|
||||
});
|
||||
|
||||
await test("媒体标签: 纯媒体开头(无前置文本)", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/hello.mp3</qqvoice>" });
|
||||
await flush(300);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(200);
|
||||
|
||||
// 验证:应该至少有流式会话创建或媒体处理的记录
|
||||
const infoLogs = logs.filter((l) => l.includes("processMediaTags") && l.includes("found"));
|
||||
assert.ok(infoLogs.length >= 1, `应检测到 qqvoice 标签。相关日志: ${infoLogs.join("; ") || "无"}`);
|
||||
});
|
||||
|
||||
console.log("\n=== 3. 多个媒体标签(循环消费) ===");
|
||||
|
||||
await test("多媒体: 两个图片标签被逐个处理", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
const text = "图1:<qqimg>/tmp/a.jpg</qqimg>\n图2:<qqimg>/tmp/b.jpg</qqimg>\n完毕";
|
||||
|
||||
await ctrl.onPartialReply({ text });
|
||||
await flush(600);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(200);
|
||||
|
||||
// 验证:日志中应显示找到了两个标签
|
||||
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
|
||||
assert.ok(foundLogs.length >= 2, `应找到至少 2 个标签,实际 ${foundLogs.length} 条 found 日志`);
|
||||
});
|
||||
|
||||
console.log("\n=== 4. 未闭合标签等待 ===");
|
||||
|
||||
await test("未闭合标签: 逐步到达后完整处理", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
// 不完整的标签
|
||||
await ctrl.onPartialReply({ text: "开始<qqimg>/tmp/pic" });
|
||||
await flush(200);
|
||||
|
||||
// 验证:此时流式文本应该只包含 "开始",不含标签部分
|
||||
const midCalls = [...streamCalls];
|
||||
for (const call of midCalls) {
|
||||
assert.ok(!call.content.includes("<qqimg>"), `中间态不应包含未闭合标签,内容: "${call.content}"`);
|
||||
}
|
||||
|
||||
// 标签完整了
|
||||
await ctrl.onPartialReply({ text: "开始<qqimg>/tmp/pic.jpg</qqimg>\n看看" });
|
||||
await flush(300);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(200);
|
||||
|
||||
// 验证:应检测到完整标签
|
||||
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
|
||||
assert.ok(foundLogs.length >= 1, `标签完整后应被检测到,found 日志: ${foundLogs.length}`);
|
||||
});
|
||||
|
||||
console.log("\n=== 5. ★ pendingNormalizedFull 补救机制 ===");
|
||||
|
||||
await test("补救: 媒体处理期间最后一次 onPartialReply 不丢失", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
// 设置 API 有延迟,模拟 processMediaTags 执行耗时
|
||||
apiDelayMs = 50;
|
||||
mediaApiDelayMs = 80;
|
||||
|
||||
// 第1次: 含媒体标签 → 进入 processMediaTags,mediaInterruptInProgress=true
|
||||
const p1 = ctrl.onPartialReply({ text: "hi<qqimg>/tmp/x.jpg</qqimg>" });
|
||||
|
||||
// 等一小段确保 processMediaTags 已经开始
|
||||
await sleep(20);
|
||||
|
||||
// 第2次: 这是"最后一次" onPartialReply —— 带有新的后续文本
|
||||
// 因为 mediaInterruptInProgress=true,会被保存到 pendingNormalizedFull
|
||||
const p2 = ctrl.onPartialReply({ text: "hi<qqimg>/tmp/x.jpg</qqimg>\n\n再见朋友" });
|
||||
|
||||
// 等待所有处理完成(包括 deferred re-run)
|
||||
await p1;
|
||||
await p2;
|
||||
await flush(800);
|
||||
|
||||
// 标记完成
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(300);
|
||||
|
||||
// ★ 核心验证:最终发送的文本应包含 "再见朋友"
|
||||
const allStreamContent = streamCalls.map((c) => c.content).join(" || ");
|
||||
assert.ok(
|
||||
allStreamContent.includes("再见朋友"),
|
||||
`"再见朋友" 应出现在流式发送中(pendingNormalizedFull 补救)。\n实际流式内容: [\n${streamCalls.map((c, i) => ` ${i}: "${c.content.slice(0, 80)}" (state=${c.inputState})`).join("\n")}\n]`
|
||||
);
|
||||
|
||||
// 验证:deferred 日志应出现
|
||||
const deferredLogs = logs.filter((l) => l.includes("deferred"));
|
||||
assert.ok(deferredLogs.length >= 1, `应有 deferred 相关日志,实际: ${deferredLogs.length}`);
|
||||
});
|
||||
|
||||
await test("补救: 多次被跳过只保留最新,最终处理最新文本", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
apiDelayMs = 30;
|
||||
mediaApiDelayMs = 150; // 媒体处理很慢
|
||||
|
||||
// 第1次: 含媒体 → 进入长时间处理
|
||||
const p1 = ctrl.onPartialReply({ text: "<qqvoice>/tmp/song.mp3</qqvoice>" });
|
||||
await sleep(20);
|
||||
|
||||
// 第2次: 被跳过 → pendingNormalizedFull = "..v1.."
|
||||
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/song.mp3</qqvoice>\n后续文字v1" });
|
||||
await sleep(10);
|
||||
|
||||
// 第3次: 被跳过 → pendingNormalizedFull 被覆盖为 "..v2.."
|
||||
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/song.mp3</qqvoice>\n后续文字v2最终版" });
|
||||
|
||||
await p1;
|
||||
await flush(800);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(300);
|
||||
|
||||
// ★ 核心验证:最终应包含 v2 的文本
|
||||
const allStreamContent = streamCalls.map((c) => c.content).join(" || ");
|
||||
assert.ok(
|
||||
allStreamContent.includes("后续文字v2最终版"),
|
||||
`应包含最新的 "后续文字v2最终版"。\n实际: [\n${streamCalls.map((c, i) => ` ${i}: "${c.content.slice(0, 80)}" (state=${c.inputState})`).join("\n")}\n]`
|
||||
);
|
||||
});
|
||||
|
||||
await test("补救: 无 pending 时不触发多余的 re-run", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
apiDelayMs = 5;
|
||||
mediaApiDelayMs = 5;
|
||||
|
||||
// 只有一次 onPartialReply(媒体标签后接 \n 开头的文本)
|
||||
await ctrl.onPartialReply({ text: "hello<qqimg>/tmp/a.jpg</qqimg>\nbye" });
|
||||
await flush(400);
|
||||
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
await flush(200);
|
||||
|
||||
// 验证:不应有 re-running 日志(因为没有被跳过的调用)
|
||||
const reRunLogs = logs.filter((l) => l.includes("re-running"));
|
||||
assert.strictEqual(reRunLogs.length, 0, `不应有 re-running 日志,实际: ${reRunLogs.length}`);
|
||||
|
||||
// ★ 验证:不应有 PREFIX MISMATCH 错误(之前 stripMediaTags 的 .trim() 会导致此问题)
|
||||
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
|
||||
assert.strictEqual(prefixMismatchLogs.length, 0, `不应有 PREFIX MISMATCH,实际: ${prefixMismatchLogs.join("; ")}`);
|
||||
});
|
||||
|
||||
console.log("\n=== 6. onIdle 边界 ===");
|
||||
|
||||
await test("onIdle: 等待媒体处理完成后再终结", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
apiDelayMs = 20;
|
||||
mediaApiDelayMs = 200; // 媒体发送很慢
|
||||
|
||||
// 发送含媒体的文本
|
||||
const p = ctrl.onPartialReply({ text: "<qqimg>/tmp/slow.jpg</qqimg>\n完成" });
|
||||
await sleep(30);
|
||||
|
||||
// 在媒体还在处理时就标记完成并触发 onIdle
|
||||
ctrl.markFullyComplete();
|
||||
const idlePromise = ctrl.onIdle();
|
||||
|
||||
await p;
|
||||
await idlePromise;
|
||||
await flush(500);
|
||||
|
||||
// 验证:应该正常完成,不降级
|
||||
assert.ok(!ctrl.shouldFallbackToStatic, "不应降级到静态发送");
|
||||
});
|
||||
|
||||
await test("onDeliver: deliver 先到达 → 禁用流式走降级", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
// deliver 先到达(此时 sentStreamChunkCount === 0)→ 直接 transition 到 aborted
|
||||
await ctrl.onDeliver({ text: "成果:<qqimg>/tmp/result.png</qqimg>" });
|
||||
await flush(400);
|
||||
|
||||
// 验证:应该已经进入终态(aborted),走降级路径
|
||||
assert.ok(ctrl.isTerminalPhase, "deliver 先到达后应进入终态");
|
||||
assert.ok(ctrl.shouldFallbackToStatic, "deliver 先到达时应降级到静态发送");
|
||||
|
||||
// 后续 onPartialReply 应被跳过(因为已是终态)
|
||||
await ctrl.onPartialReply({ text: "这段应该被忽略" });
|
||||
assert.ok(ctrl.shouldFallbackToStatic, "onPartialReply 应被跳过,仍然是降级状态");
|
||||
});
|
||||
|
||||
await test("互斥: onPartialReply 先到 → onDeliver 被忽略(即使在媒体中断期间)", async () => {
|
||||
const ctrl = createController({ mediaContext: true });
|
||||
|
||||
// onPartialReply 先到 → 锁定为 partial 模式
|
||||
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/a.mp3</qqvoice>" });
|
||||
await flush(400);
|
||||
|
||||
// 此时可能还在 mediaInterruptInProgress,sentStreamChunkCount 可能为 0
|
||||
// 但 onDeliver 应被忽略(因为 partial 先到)
|
||||
await ctrl.onDeliver({ text: "<qqvoice>/tmp/a.mp3</qqvoice>" });
|
||||
await flush(400);
|
||||
|
||||
// 验证:不应降级(deliver 没有生效)
|
||||
assert.ok(!ctrl.shouldFallbackToStatic, "partial 先到时 deliver 不应导致降级");
|
||||
assert.ok(!ctrl.isTerminalPhase || ctrl.currentPhase !== "aborted",
|
||||
"不应因为 deliver 进入 aborted(onPartialReply 在处理中)");
|
||||
|
||||
// 日志中应有 deliver 被拒绝的字样
|
||||
const skipLogs = logs.filter((l) => l.includes('rejected "deliver"'));
|
||||
assert.ok(skipLogs.length >= 1, `应有 deliver 被拒绝的日志,实际: ${skipLogs.join("; ") || "无"}`);
|
||||
});
|
||||
|
||||
console.log("\n=== 7. 降级与异常 ===");
|
||||
|
||||
await test("降级: 从未发送分片 → fallback", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
// 不发送任何文本就直接结束
|
||||
ctrl.markFullyComplete();
|
||||
await ctrl.onIdle();
|
||||
|
||||
assert.ok(ctrl.isTerminalPhase, "应进入终态");
|
||||
});
|
||||
|
||||
await test("异常: onError 后正常终态", async () => {
|
||||
const ctrl = createController();
|
||||
|
||||
await ctrl.onPartialReply({ text: "部分文本" });
|
||||
await flush(200);
|
||||
|
||||
await ctrl.onError(new Error("test error"));
|
||||
|
||||
assert.ok(ctrl.isTerminalPhase, "onError 后应进入终态");
|
||||
});
|
||||
|
||||
console.log("\n=== 8. 回复边界检测 ===");
|
||||
|
||||
await test("回复边界: 文本缩短 → 旧controller终结,新controller处理新回复", async () => {
|
||||
let newCtrl: StreamingControllerType | null = null;
|
||||
|
||||
const ctrl = createController({
|
||||
onReplyBoundary: async (newText: string) => {
|
||||
// 回调中创建新 controller 并处理新回复
|
||||
newCtrl = createController();
|
||||
await newCtrl.onPartialReply({ text: newText });
|
||||
},
|
||||
});
|
||||
|
||||
// 第一段回复
|
||||
await ctrl.onPartialReply({ text: "第一段回复内容比较长" });
|
||||
await flush();
|
||||
|
||||
// 记录第一段相关的 streamCalls 数量
|
||||
const firstSegCalls = streamCalls.length;
|
||||
assert.ok(firstSegCalls > 0, "第一段应已产生流式调用");
|
||||
|
||||
// 文本缩短 → 触发回复边界
|
||||
await ctrl.onPartialReply({ text: "短" });
|
||||
await flush();
|
||||
|
||||
// 旧 controller 应已进入终态
|
||||
assert.ok(ctrl.isTerminalPhase, "旧 controller 应已进入终态");
|
||||
// 新 controller 应已创建
|
||||
assert.ok(newCtrl !== null, "应通过回调创建了新 controller");
|
||||
|
||||
// 第一段应有 DONE 分片(终结)
|
||||
const doneCalls = streamCalls.filter((c) => c.inputState === 10);
|
||||
assert.ok(doneCalls.length >= 1, "旧 controller 应发送了 DONE 分片终结第一段");
|
||||
|
||||
// 验证第一段的 DONE 分片包含第一段内容
|
||||
const firstDone = doneCalls[0];
|
||||
assert.ok(firstDone.content.includes("第一段回复内容比较长"), `第一段 DONE 分片应包含 "第一段回复内容比较长", 实际: "${firstDone.content}"`);
|
||||
|
||||
// 继续第二段回复增长
|
||||
await newCtrl!.onPartialReply({ text: "短回复完整" });
|
||||
await flush();
|
||||
|
||||
newCtrl!.markFullyComplete();
|
||||
await newCtrl!.onIdle();
|
||||
await flush();
|
||||
|
||||
// 验证新 controller 的流式调用包含第二段内容
|
||||
// 新 controller 的调用在 firstSegCalls 之后(因为 streamCalls 是全局的,但 DONE 会增加一些)
|
||||
const allContent = streamCalls.map((c) => c.content).join(" || ");
|
||||
assert.ok(allContent.includes("短回复完整"), `应包含第二段 "短回复完整",实际: ${allContent}`);
|
||||
|
||||
// 两段内容是独立的流式消息,不应混在一起
|
||||
const lastCall = streamCalls[streamCalls.length - 1];
|
||||
assert.ok(!lastCall.content.includes("第一段回复内容比较长"), "第二段最终分片不应包含第一段内容(各自独立)");
|
||||
});
|
||||
|
||||
// ============ 结果 ============
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(` 总计: ${passed + failed} | ✅ 通过: ${passed} | ❌ 失败: ${failed}`);
|
||||
if (failedTests.length > 0) {
|
||||
console.log(` 失败用例:`);
|
||||
for (const t of failedTests) console.log(` - ${t}`);
|
||||
}
|
||||
console.log(`========================================\n`);
|
||||
|
||||
// 恢复原始 fetch
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
843
tests/strip-incomplete-media-tag.test.ts
Normal file
843
tests/strip-incomplete-media-tag.test.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* stripIncompleteMediaTag 单元测试
|
||||
*
|
||||
* 运行方式: npx tsx tests/strip-incomplete-media-tag.test.ts
|
||||
*/
|
||||
|
||||
import { stripIncompleteMediaTag } from "../src/utils/media-send.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failedTests: string[] = [];
|
||||
|
||||
function test(name: string, input: string, expectedSafe: string, expectedIncomplete: boolean) {
|
||||
const [safe, incomplete] = stripIncompleteMediaTag(input);
|
||||
try {
|
||||
assert.strictEqual(safe, expectedSafe, `safeText mismatch`);
|
||||
assert.strictEqual(incomplete, expectedIncomplete, `hasIncomplete mismatch`);
|
||||
console.log(` ✅ ${name}`);
|
||||
passed++;
|
||||
} catch (e: any) {
|
||||
console.log(` ❌ ${name}`);
|
||||
console.log(` 输入: ${JSON.stringify(input)}`);
|
||||
console.log(` 期望: [${JSON.stringify(expectedSafe)}, ${expectedIncomplete}]`);
|
||||
console.log(` 实际: [${JSON.stringify(safe)}, ${incomplete}]`);
|
||||
failed++;
|
||||
failedTests.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 空值 / 无标签文本 → 不截断
|
||||
// ==========================================
|
||||
console.log("\n=== 1. 空值 / 无标签文本 ===");
|
||||
|
||||
test("空字符串", "", "", false);
|
||||
test("纯文本", "这是一段普通文字", "这是一段普通文字", false);
|
||||
test("含普通HTML标签", "这是<b>加粗</b>文字", "这是<b>加粗</b>文字", false);
|
||||
test("含换行的纯文本", "第一行\n第二行\n第三行", "第一行\n第二行\n第三行", false);
|
||||
test("只有换行", "\n\n\n", "\n\n\n", false);
|
||||
test("以换行结尾(最后一行为空)", "text\n", "text\n", false);
|
||||
test("多行以换行结尾", "abc\ndef\n", "abc\ndef\n", false);
|
||||
test("纯空白", " ", " ", false);
|
||||
test("< 后接空格(非标签)", "正文< ", "正文< ", false);
|
||||
test("< 后接数字(如 < 3)", "条件 < 3 成立", "条件 < 3 成立", false);
|
||||
test("数学公式 3 < 5 > 2", "计算结果: 3 < 5 > 2,完毕", "计算结果: 3 < 5 > 2,完毕", false);
|
||||
test("文本末尾恰好是 >", "3 < 5 > 2 结果为 true>", "3 < 5 > 2 结果为 true>", false);
|
||||
|
||||
// ==========================================
|
||||
// 2. 完全闭合的媒体标签 → 不截断
|
||||
// ==========================================
|
||||
console.log("\n=== 2. 完整闭合标签(不应截断)===");
|
||||
|
||||
test(
|
||||
"完整 qqvoice 标签",
|
||||
"前文<qqvoice>/tmp/joke.mp3</qqvoice>后文",
|
||||
"前文<qqvoice>/tmp/joke.mp3</qqvoice>后文",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"完整 qqimg 标签",
|
||||
"看图<qqimg>/path/to/img.jpg</qqimg>",
|
||||
"看图<qqimg>/path/to/img.jpg</qqimg>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"完整标签后有文本",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话 😄",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话 😄",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"纯标签完整闭合",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice>",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"完整标签在末尾",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice>",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"完整标签后有普通 < 字符",
|
||||
"<qqvoice>/a.mp3</qqvoice> 结果是 3 < 5",
|
||||
"<qqvoice>/a.mp3</qqvoice> 结果是 3 < 5",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"完整标签后跟 </b>(无 >)— 非媒体不影响",
|
||||
"正文<qqvoice>/a.mp3</qqvoice>后文</b",
|
||||
"正文<qqvoice>/a.mp3</qqvoice>后文</b",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"完整标签后跟 </b>(有 >)— 非媒体不影响",
|
||||
"正文<qqvoice>/a.mp3</qqvoice>后文</b>",
|
||||
"正文<qqvoice>/a.mp3</qqvoice>后文</b>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"标签内容含 > 且完整闭合",
|
||||
"前文<qqvoice>/tmp/笑话>_< .mp3</qqvoice>",
|
||||
"前文<qqvoice>/tmp/笑话>_< .mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
">_</qqvoice> 完整闭合(内容含 >_<)",
|
||||
"前文<qqvoice>/tmp/笑话>_</qqvoice>",
|
||||
"前文<qqvoice>/tmp/笑话>_</qqvoice>",
|
||||
false,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 3. 不完整的「开标签」→ 截断到该 < 前面
|
||||
// ==========================================
|
||||
console.log("\n=== 3. 不完整的开标签 ===");
|
||||
|
||||
test("孤立 < 在行尾", "这是正文<", "这是正文", true);
|
||||
test("<qq", "这是正文<qq", "这是正文", true);
|
||||
test("<qqvoice", "这是正文<qqvoice", "这是正文", true);
|
||||
test("<qqimg>(有 > 但无闭合标签)", "这是正文<qqimg>", "这是正文", true);
|
||||
test("<qqimg>/path", "这是正文<qqimg>/path/to", "这是正文", true);
|
||||
test("<qqimg>/path/full(开标签有 > 但无闭合)", "这是正文<qqimg>/path/to/img.jpg", "这是正文", true);
|
||||
test("纯开标签前缀(无前文)", "<qqvoice>/tmp/joke.mp3", "", true);
|
||||
test("<i 是 <img 的前缀 → 截断", "正文<i", "正文", true);
|
||||
test("<img", "正文<img", "正文", true);
|
||||
test("<image", "正文<image", "正文", true);
|
||||
test("<video", "正文<video", "正文", true);
|
||||
test("<audio", "正文<audio", "正文", true);
|
||||
test("<file", "正文<file", "正文", true);
|
||||
test("<doc", "正文<doc", "正文", true);
|
||||
test("<media", "正文<media", "正文", true);
|
||||
test("<attach", "正文<attach", "正文", true);
|
||||
test("<send", "正文<send", "正文", true);
|
||||
test("<pic", "正文<pic", "正文", true);
|
||||
test("<photo", "正文<photo", "正文", true);
|
||||
test("<document", "正文<document", "正文", true);
|
||||
test("<picture", "正文<picture", "正文", true);
|
||||
test("开标签有 > 但无闭合标签", "前文<qqvoice>/path/to/file.mp3", "前文", true);
|
||||
|
||||
// ==========================================
|
||||
// 4. 不完整的「闭合标签」→ 回溯到开标签前面截断
|
||||
// ★ 这是核心原则的关键场景
|
||||
// ==========================================
|
||||
console.log("\n=== 4. 不完整闭合标签(回溯到开标签)===");
|
||||
|
||||
test(
|
||||
"< — 闭合标签刚开始",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3<",
|
||||
"这是正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</ — 闭合标签刚开始",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</",
|
||||
"这是正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</qq — 闭合标签名部分",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qq",
|
||||
"这是正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</qqvoice — 闭合标签名完整但缺 >",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice",
|
||||
"这是正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</qqimg — qqimg 闭合标签前缀",
|
||||
"这是正文<qqimg>/path/to/img.jpg</qqimg",
|
||||
"这是正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"有前文 + </ 闭合前缀",
|
||||
"你好呀!<qqvoice>/tmp/a.mp3</",
|
||||
"你好呀!",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"纯标签 + 闭合前缀(无前文)",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice",
|
||||
"",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"标签内容含 > 字符 + 闭合未完成",
|
||||
"前文<qqvoice>/tmp/笑话>_< .mp3</qqvoice",
|
||||
"前文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
">_</qqvoice 未闭合(内容含 >_<)",
|
||||
"前文<qqvoice>/tmp/笑话>_</qqvoice",
|
||||
"前文",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 5. 闭合标签前缀但前面无对应开标签(防御场景)
|
||||
// ==========================================
|
||||
console.log("\n=== 5. 闭合标签前缀但无开标签(防御)===");
|
||||
|
||||
test(
|
||||
"</qqvoice 无开标签(单行)→ 截掉整个最后一行",
|
||||
"普通文字</qqvoice",
|
||||
"普通文字</qqvoice",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</qqvoice 无开标签(多行)→ 只截掉最后一行",
|
||||
"前面的安全内容\n普通文字</qqvoice",
|
||||
"前面的安全内容\n普通文字</qqvoice",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"纯 </",
|
||||
"</",
|
||||
"</",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"纯 </qqvoice 无开标签",
|
||||
"</qqvoice",
|
||||
"</qqvoice",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</随便 不是媒体标签 → 安全",
|
||||
"普通文字</div",
|
||||
"普通文字</div",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"非媒体闭合标签 </div → 安全",
|
||||
"普通文字</div",
|
||||
"普通文字</div",
|
||||
false,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 6. 多行文本 — 只检查最后一行
|
||||
// ==========================================
|
||||
console.log("\n=== 6. 多行文本(只检查最后一行)===");
|
||||
|
||||
test(
|
||||
"前面行有未闭合,但最后一行安全",
|
||||
"第一行<qqvoice\n第二行完整<qqimg>/b.jpg</qqimg>",
|
||||
"第一行<qqvoice\n第二行完整<qqimg>/b.jpg</qqimg>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"前面行安全,最后一行未闭合",
|
||||
"第一行完整\n第二行<qqimg>/b.jpg",
|
||||
"第一行完整\n第二行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"多行 + 最后一行是闭合标签前缀",
|
||||
"第一行\n前文<qqvoice>/a.mp3</qqvoice",
|
||||
"第一行\n前文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"多行 + 最后一行以换行结尾 → 安全",
|
||||
"第一行\n<qqimg>stuff\n",
|
||||
"第一行\n<qqimg>stuff\n",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"多行 + 最后一行有闭合标签回溯到开标签",
|
||||
"行一\n行二\n看看<qqimg>/path/to/img.jpg</qqimg",
|
||||
"行一\n行二\n看看",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 6.5 多行 — 前面行无论标签是否匹配,都必须完整保留
|
||||
// (只检查最后一行,前面行一定是 safeText 的一部分)
|
||||
// ==========================================
|
||||
console.log("\n=== 6.5 前面行一定保留 ===");
|
||||
|
||||
test(
|
||||
"前面行有未闭合媒体开标签 + 最后一行截断 → 前面行完整保留",
|
||||
"第一行<qqvoice\n最后一行<qqimg>/b.jpg",
|
||||
"第一行<qqvoice\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有未闭合媒体闭合标签 + 最后一行截断 → 前面行完整保留",
|
||||
"第一行</qqvoice\n最后一行<qqimg>/b.jpg",
|
||||
"第一行</qqvoice\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有孤立 < + 最后一行截断 → 前面行完整保留",
|
||||
"第一行内容<\n最后一行<qqvoice>/a.mp3",
|
||||
"第一行内容<\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有孤立 </ + 最后一行截断 → 前面行完整保留",
|
||||
"第一行内容</\n最后一行<qqimg",
|
||||
"第一行内容</\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有不完整媒体标签对 + 最后一行截断 → 前面行完整保留",
|
||||
"第一行<qqimg>/path</qqimg\n最后一行<qqvoice>/a.mp3",
|
||||
"第一行<qqimg>/path</qqimg\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有完整标签+不完整标签混合 + 最后一行截断",
|
||||
"<qqvoice>/a.mp3</qqvoice>然后<qqimg\n新的一行<qqvoice>/b.mp3",
|
||||
"<qqvoice>/a.mp3</qqvoice>然后<qqimg\n新的一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面多行都有各种标签 + 最后一行截断 → 前面全部保留",
|
||||
"第1行<qqvoice\n第2行</qqimg\n第3行<img>\n最后<qqvoice>/a.mp3",
|
||||
"第1行<qqvoice\n第2行</qqimg\n第3行<img>\n最后",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有媒体前缀标签 <i + 最后一行截断 → 前面行完整保留",
|
||||
"第一行<i\n最后一行<qqvoice>/a.mp3",
|
||||
"第一行<i\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有非媒体标签 + 最后一行截断 → 前面行完整保留",
|
||||
"第一行<div>内容</div>\n<b>加粗\n最后一行<qqimg>/b.jpg",
|
||||
"第一行<div>内容</div>\n<b>加粗\n最后一行",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"前面行有完整媒体标签 + 最后一行安全 → 全部保留",
|
||||
"<qqvoice>/a.mp3</qqvoice>\n最后一行安全文字",
|
||||
"<qqvoice>/a.mp3</qqvoice>\n最后一行安全文字",
|
||||
false,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 7. 多个标签场景(同一行)
|
||||
// ==========================================
|
||||
console.log("\n=== 7. 多标签场景(同一行)===");
|
||||
|
||||
test(
|
||||
"完整标签后跟不完整开标签",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间<qqimg>/b.jpg",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整标签后跟 <",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>后续<",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>后续",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整标签 + 另一个标签的闭合前缀(截到第二个的开标签前)",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间<qqimg>/b.jpg</qqimg",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整标签 + 第二个闭合缺>",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间<qqvoice>/b.mp3</qqvoice",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整标签后跟 </(孤立闭合前缀,回溯到开标签)",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间<qqimg>/b.jpg</",
|
||||
"前文<qqvoice>/a.mp3</qqvoice>中间",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整标签 + 普通HTML + 未闭合媒体标签",
|
||||
"<qqvoice>/a.mp3</qqvoice>普通文本<b>加粗</b><qqimg>/b.jpg</qqimg",
|
||||
"<qqvoice>/a.mp3</qqvoice>普通文本<b>加粗</b>",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"两个完整标签紧挨",
|
||||
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>",
|
||||
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"两个同名完整标签中间有文字",
|
||||
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>",
|
||||
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"两个同名完整标签 + 末尾未闭合",
|
||||
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>后面<qqimg>/c.jpg",
|
||||
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>后面",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"第一个未闭合 + 第二个完整(从右到左,先看到完整的闭合标签→安全?不!)",
|
||||
// 从右到左:先找到 </qqvoice> 完整闭合 → 对应哪个开标签?
|
||||
// 实际上从右到左找到的第一个媒体 < 是 </qqvoice> → 完整闭合 → 返回安全
|
||||
// 但第一个 <qqvoice> 其实没有闭合!
|
||||
// 这是一个设计取舍:由于我们只从右到左找第一个媒体标签,不做全行配对
|
||||
// 如果最右边的标签对完整,就认为安全
|
||||
"<qqvoice>/a.mp3中间文字<qqvoice>/b.mp3</qqvoice>",
|
||||
// 从右到左:</qqvoice> 完整 → safe
|
||||
// 注意:这里第一个 <qqvoice> 没有 > 所以不算完整开标签
|
||||
// 从右到左找:先 </qqvoice> 完整闭合 → 安全返回
|
||||
// 但实际上 <qqvoice>/a.mp3 是个没有闭合的开标签
|
||||
// 这个场景可能返回 safe 也可能返回 unsafe,取决于实现
|
||||
// 实际代码从右到左找,第一个遇到的媒体 < 是 </qqvoice>(行尾),完整 → safe=false
|
||||
// 但这是有 bug 的... 让我先测试看实际行为
|
||||
"<qqvoice>/a.mp3中间文字<qqvoice>/b.mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 8. 全角中文尖括号 <>
|
||||
// ==========================================
|
||||
console.log("\n=== 8. 中文全角尖括号 ===");
|
||||
|
||||
test("中文尖括号开标签", "这是正文<qqvoice", "这是正文", true);
|
||||
test("孤立中文尖括号", "这是正文<", "这是正文", true);
|
||||
test(
|
||||
"中文尖括号完整标签",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice>",
|
||||
// <qqvoice> 是开标签(用 < 开头,> 结尾),检查后面有没有 </qqvoice>
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"中文尖括号闭合标签前缀",
|
||||
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice",
|
||||
// </qqvoice 是不完整闭合标签 → needFindOpenTag=qqvoice → 往左找 <qqvoice → 截断
|
||||
"这是正文",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 9. 各种媒体标签名测试
|
||||
// ==========================================
|
||||
console.log("\n=== 9. 各种媒体标签名 ===");
|
||||
|
||||
test("img 标签未闭合", "正文<img src='x'>content", "正文", true);
|
||||
test("image 标签未闭合", "正文<image>content", "正文", true);
|
||||
test("video 标签未闭合", "正文<video>content", "正文", true);
|
||||
test("audio 标签未闭合", "正文<audio>content", "正文", true);
|
||||
test("voice 标签未闭合", "正文<voice>content", "正文", true);
|
||||
test("file 标签未闭合", "正文<file>content", "正文", true);
|
||||
test("doc 标签未闭合", "正文<doc>content", "正文", true);
|
||||
test("media 标签未闭合", "正文<media>content", "正文", true);
|
||||
test("attach 标签未闭合", "正文<attach>content", "正文", true);
|
||||
test("send 标签未闭合", "正文<send>content", "正文", true);
|
||||
test("pic 标签未闭合", "正文<pic>content", "正文", true);
|
||||
test("photo 标签未闭合", "正文<photo>content", "正文", true);
|
||||
test("document 标签未闭合", "正文<document>content", "正文", true);
|
||||
test("picture 标签未闭合", "正文<picture>content", "正文", true);
|
||||
|
||||
// 非媒体标签名不应截断
|
||||
test("非媒体标签 <div> 不截断", "正文<div>content", "正文<div>content", false);
|
||||
test("非媒体标签 <span> 不截断", "正文<span>content", "正文<span>content", false);
|
||||
test("非媒体标签 <b> 不截断", "正文<b>content", "正文<b>content", false);
|
||||
test("非媒体标签 <a> 不截断", "正文<a href='x'>link", "正文<a href='x'>link", false);
|
||||
|
||||
// 大小写
|
||||
test("大写 <QQVOICE 也算媒体标签", "正文<QQVOICE", "正文", true);
|
||||
test("混合大小写 <QqImg", "正文<QqImg", "正文", true);
|
||||
|
||||
// ==========================================
|
||||
// 10. 纯媒体标签(没有前置文本)
|
||||
// ==========================================
|
||||
console.log("\n=== 10. 纯媒体标签(无前置文本)===");
|
||||
|
||||
test("纯开标签前缀", "<qqvoice>/tmp/joke.mp3", "", true);
|
||||
test("纯标签 + 闭合前缀", "<qqvoice>/tmp/joke.mp3</qqvoice", "", true);
|
||||
test("纯标签完整闭合", "<qqvoice>/tmp/joke.mp3</qqvoice>", "<qqvoice>/tmp/joke.mp3</qqvoice>", false);
|
||||
test(
|
||||
"纯标签完整闭合 + 换行文本",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话",
|
||||
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话",
|
||||
false,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 11. 截断后 trimEnd(去掉尾部空白和换行)
|
||||
// ==========================================
|
||||
console.log("\n=== 11. 截断后 trimEnd ===");
|
||||
|
||||
test(
|
||||
"截断后有3个连续换行 → trimEnd 去掉尾部换行",
|
||||
"第一段\n\n\n<qqimg>/path.jpg",
|
||||
"第一段",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"截断后有4个连续换行 → trimEnd 去掉尾部换行",
|
||||
"段落\n\n\n\n<qqvoice>/a.mp3",
|
||||
"段落",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"截断后有2个连续换行 → trimEnd 去掉尾部换行",
|
||||
"段落\n\n<qqvoice>/a.mp3",
|
||||
"段落",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"截断后 trimEnd 去除尾部空格和换行",
|
||||
"段落 \n <qqvoice>/a.mp3",
|
||||
// 多行:最后一行是 " <qqvoice>/a.mp3"
|
||||
// < 在 lastLine 中位置 2,text.slice(0, lineStart + 2) = "段落 \n "
|
||||
// trimEnd 从末尾去除所有 \s(空格+换行),所以 "段落 \n " → "段落"
|
||||
"段落",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 12. needFindOpenTag 通配模式(孤立 </ → 找最近媒体开标签)
|
||||
// ==========================================
|
||||
console.log("\n=== 12. 孤立 </ 的通配回溯 ===");
|
||||
|
||||
test(
|
||||
"孤立 </ + 前面有媒体开标签",
|
||||
"正文<qqimg>/path/to/img.jpg</",
|
||||
"正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"孤立 </ + 前面无媒体开标签 → 原样返回",
|
||||
"纯文本</",
|
||||
"纯文本</",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"孤立 </ + 前面有非媒体标签 → 找不到媒体开标签 → 原样返回",
|
||||
"正文<div>内容</",
|
||||
"正文<div>内容</",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"多行 + 最后一行孤立 </",
|
||||
"第一行\n第二行</",
|
||||
"第一行\n第二行</",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 13. needFindOpenTag 精确匹配模式
|
||||
// ==========================================
|
||||
console.log("\n=== 13. 闭合标签名精确回溯 ===");
|
||||
|
||||
test(
|
||||
"</qqvoice 回溯找 <qqvoice",
|
||||
"正文<qqvoice>/tmp/a.mp3</qqvoice",
|
||||
"正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</qqimg 回溯找 <qqimg",
|
||||
"正文<qqimg>/path.jpg</qqimg",
|
||||
"正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"闭合标签名和开标签名不匹配(如 </qqimg 但前面是 <qqvoice)→ 找不到对应开标签,原样返回",
|
||||
// 最后一行:<qqvoice>/a.mp3</qqimg
|
||||
// 从右到左:</qqimg (不完整闭合) → needFindOpenTag=qqimg
|
||||
// 继续往左找 <qqimg 开标签,找到 <qqvoice 但名字不匹配 → continue
|
||||
// 遍历完 → needFindOpenTag 是具体标签名但找不到对应开标签 → 原样返回
|
||||
"<qqvoice>/a.mp3</qqimg",
|
||||
"<qqvoice>/a.mp3</qqimg",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 14. 复合场景 — 完整标签 + 不完整标签混合
|
||||
// ==========================================
|
||||
console.log("\n=== 14. 复合场景 ===");
|
||||
|
||||
test(
|
||||
"完整A + 未闭合B开标签",
|
||||
"<qqvoice>/a.mp3</qqvoice>然后<qqimg>/b.jpg",
|
||||
"<qqvoice>/a.mp3</qqvoice>然后",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整A + 未闭合B闭合标签(回溯到B开标签)",
|
||||
"<qqvoice>/a.mp3</qqvoice>然后<qqimg>/b.jpg</qqimg",
|
||||
"<qqvoice>/a.mp3</qqvoice>然后",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"完整A + 完整B + 未闭合C",
|
||||
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>再来<qqvoice>/c.mp3",
|
||||
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>再来",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"两个同名完整标签 + 第三个未闭合",
|
||||
"<qqvoice>/a.mp3</qqvoice>中间<qqvoice>/b.mp3</qqvoice>后面<qqvoice>/c.mp3",
|
||||
"<qqvoice>/a.mp3</qqvoice>中间<qqvoice>/b.mp3</qqvoice>后面",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 15. 标签属性场景
|
||||
// ==========================================
|
||||
console.log("\n=== 15. 标签属性 ===");
|
||||
|
||||
test(
|
||||
"开标签有属性且完整闭合",
|
||||
'正文<qqimg src="/path/img.jpg">image desc</qqimg>',
|
||||
'正文<qqimg src="/path/img.jpg">image desc</qqimg>',
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"开标签有属性但无闭合标签",
|
||||
'正文<qqimg src="/path/img.jpg">image desc',
|
||||
"正文",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"开标签有属性但未写完(无 >)",
|
||||
'正文<qqimg src="/path/img.jpg',
|
||||
"正文",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 16. 边界场景
|
||||
// ==========================================
|
||||
console.log("\n=== 16. 其他边界场景 ===");
|
||||
|
||||
test(
|
||||
"单个 < 字符(行尾)",
|
||||
"<",
|
||||
"",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"< 后紧跟 >(如 <>)— 不是有效标签",
|
||||
"文本<>",
|
||||
"文本<>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"多个连续 < 在行尾",
|
||||
"文本<<<",
|
||||
"文本<<",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"标签名后有空格再有 >",
|
||||
"正文<qqvoice >/a.mp3</qqvoice>",
|
||||
"正文<qqvoice >/a.mp3</qqvoice>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"闭合标签名后有空格再有 >",
|
||||
"正文<qqvoice>/a.mp3</qqvoice >",
|
||||
// 从右到左:找到 </qqvoice > → isClosing=true, hasBracket=true (有>), 完整闭合 → safe
|
||||
"正文<qqvoice>/a.mp3</qqvoice >",
|
||||
false,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 17. 前缀匹配补充 — 单字母/多字母前缀
|
||||
// ==========================================
|
||||
console.log("\n=== 17. 前缀匹配补充 ===");
|
||||
|
||||
// 单字母前缀,可能是媒体标签名的开头
|
||||
test("<v 是 voice/video 前缀 → 截断", "正文<v", "正文", true);
|
||||
test("<p 是 pic/photo/picture 前缀 → 截断", "正文<p", "正文", true);
|
||||
test("<d 是 doc/document 前缀 → 截断", "正文<d", "正文", true);
|
||||
test("<s 是 send 前缀 → 截断", "正文<s", "正文", true);
|
||||
test("<a 是 audio/attach 前缀 → 截断", "正文<a", "正文", true);
|
||||
test("<f 是 file 前缀 → 截断", "正文<f", "正文", true);
|
||||
test("<m 是 media 前缀 → 截断", "正文<m", "正文", true);
|
||||
test("<q 是 qq 前缀 → 截断", "正文<q", "正文", true);
|
||||
|
||||
// 多字母前缀
|
||||
test("<vo 是 voice 前缀 → 截断", "正文<vo", "正文", true);
|
||||
test("<ph 是 photo 前缀 → 截断", "正文<ph", "正文", true);
|
||||
test("<do 是 doc/document 前缀 → 截断", "正文<do", "正文", true);
|
||||
test("<at 是 attach 前缀 → 截断", "正文<at", "正文", true);
|
||||
test("<pi 是 pic/picture 前缀 → 截断", "正文<pi", "正文", true);
|
||||
test("<se 是 send 前缀 → 截断", "正文<se", "正文", true);
|
||||
test("<au 是 audio 前缀 → 截断", "正文<au", "正文", true);
|
||||
test("<qqv 是 qqvoice/qqvideo 前缀 → 截断", "正文<qqv", "正文", true);
|
||||
test("<qqp 是 qqpic/qqphoto/qqpicture 前缀 → 截断", "正文<qqp", "正文", true);
|
||||
|
||||
// 不是任何媒体标签的前缀 → 不截断
|
||||
test("<x 不是媒体前缀 → 不截断", "正文<x", "正文<x", false);
|
||||
test("<z 不是媒体前缀 → 不截断", "正文<z", "正文<z", false);
|
||||
test("<b 不是媒体前缀 → 不截断", "正文<b", "正文<b", false);
|
||||
test("<h 不是媒体前缀 → 不截断", "正文<h", "正文<h", false);
|
||||
test("<div 不是媒体前缀 → 不截断", "正文<div", "正文<div", false);
|
||||
test("<span 不是媒体前缀 → 不截断", "正文<span", "正文<span", false);
|
||||
|
||||
// ==========================================
|
||||
// 18. 前缀匹配 + 已有 > 闭合 → 不应截断
|
||||
// ==========================================
|
||||
console.log("\n=== 18. 前缀标签已闭合 ===");
|
||||
|
||||
test("<i>text — <i> 虽是 img 前缀但已有 >,非媒体标签 → 不截断", "正文<i>text", "正文<i>text", false);
|
||||
test("<v>text — <v> 有闭合 >,非媒体标签 → 不截断", "正文<v>text", "正文<v>text", false);
|
||||
test("<p>text — <p> 有闭合 >,非媒体标签 → 不截断", "正文<p>text", "正文<p>text", false);
|
||||
test("<a href>link — <a> 有闭合 >,不截断", "正文<a href='x'>link", "正文<a href='x'>link", false);
|
||||
|
||||
// ==========================================
|
||||
// 19. 闭合标签前缀回溯补充
|
||||
// ==========================================
|
||||
console.log("\n=== 19. 闭合标签前缀回溯补充 ===");
|
||||
|
||||
test("</v 回溯找 <voice → 截断", "正文<voice>/a.mp3</v", "正文", true);
|
||||
test("</v 回溯找 <video → 截断", "正文<video>/a.mp4</v", "正文", true);
|
||||
test("</i 回溯找 <img → 截断", "正文<img>/a.jpg</i", "正文", true);
|
||||
test("</q 回溯找 <qqvoice → 截断", "正文<qqvoice>/a.mp3</q", "正文", true);
|
||||
test("</qqv 回溯找 <qqvoice → 截断", "正文<qqvoice>/a.mp3</qqv", "正文", true);
|
||||
test("</x 不是媒体前缀 → 不截断", "正文</x", "正文</x", false);
|
||||
test("</b 不是媒体前缀 → 不截断", "正文</b", "正文</b", false);
|
||||
|
||||
// 闭合前缀无对应开标签 → 原样返回
|
||||
test("</v 无开标签 → 原样返回", "纯文本</v", "纯文本</v", true);
|
||||
test("</i 无开标签 → 原样返回", "纯文本</i", "纯文本</i", true);
|
||||
test("</qqv 无开标签 → 原样返回", "纯文本</qqv", "纯文本</qqv", true);
|
||||
|
||||
// ==========================================
|
||||
// 20. 回溯跳过已完整闭合对
|
||||
// ==========================================
|
||||
console.log("\n=== 20. 回溯跳过已闭合对 ===");
|
||||
|
||||
test(
|
||||
"</ 回溯时跳过已完整闭合的标签对",
|
||||
"正文<qqimg>/a.jpg</qqimg>中间<qqvoice>/b.mp3</qqvoice>后面</",
|
||||
// </ 触发回溯找未闭合的媒体开标签
|
||||
// <qqvoice> 有 </qqvoice> → 完整对 → 跳过
|
||||
// <qqimg> 有 </qqimg> → 完整对 → 跳过
|
||||
// 找不到 → 原样返回
|
||||
"正文<qqimg>/a.jpg</qqimg>中间<qqvoice>/b.mp3</qqvoice>后面</",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"</qqimg 回溯跳过已闭合对找到未闭合开标签",
|
||||
"前文<qqimg>/a.jpg<qqvoice>/b.mp3</qqvoice>后面</qqimg",
|
||||
// </qqimg → 回溯找 <qqimg
|
||||
// <qqvoice> 有 </qqvoice> 完整对 → 跳过
|
||||
// <qqimg> 没有完整闭合对 → 匹配!截断到这里
|
||||
"前文",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 21. 自闭合标签和 </> 场景
|
||||
// ==========================================
|
||||
console.log("\n=== 21. 特殊标签格式 ===");
|
||||
|
||||
test("</> 不是有效标签名 → 安全", "文本</>", "文本</>", false);
|
||||
test("自闭合 <qqimg/>(有 > 但无闭合标签对)→ 截断", "正文<qqimg/>", "正文", true);
|
||||
|
||||
// ==========================================
|
||||
// 22. 极端边界 — 连续和混合
|
||||
// ==========================================
|
||||
console.log("\n=== 22. 极端边界 ===");
|
||||
|
||||
test("只有 <", "<", "", true);
|
||||
test("只有 </", "</", "</", true);
|
||||
test("只有 <qqvoice", "<qqvoice", "", true);
|
||||
test("只有 </qqvoice", "</qqvoice", "</qqvoice", true);
|
||||
test("< 后面紧跟 /qqvoice>(完整闭合标签,无开标签)", "</qqvoice>", "</qqvoice>", false);
|
||||
test("连续两个孤立 <", "文本<<", "文本<", true);
|
||||
test("开标签后紧跟另一个 <", "正文<qqimg><", "正文", true);
|
||||
test("非媒体标签后面跟 <", "正文<div><", "正文<div>", true);
|
||||
test(
|
||||
"很长的文本 + 末尾未闭合",
|
||||
"这是一段很长很长很长的文本,包含了很多内容。" + "<qqvoice>/very/long/path/to/audio/file.mp3",
|
||||
"这是一段很长很长很长的文本,包含了很多内容。",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"emoji + 媒体标签",
|
||||
"哈哈😄🎉<qqimg>/img.jpg</qqimg>",
|
||||
"哈哈😄🎉<qqimg>/img.jpg</qqimg>",
|
||||
false,
|
||||
);
|
||||
test(
|
||||
"emoji + 未闭合媒体标签",
|
||||
"哈哈😄🎉<qqimg>/img.jpg",
|
||||
"哈哈😄🎉",
|
||||
true,
|
||||
);
|
||||
test(
|
||||
"中英混合 + 未闭合",
|
||||
"Hello你好World世界<qqvoice>/a.mp3",
|
||||
"Hello你好World世界",
|
||||
true,
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 23. 流式输出逐步增长模拟
|
||||
// ==========================================
|
||||
console.log("\n=== 23. 流式输出模拟 ===");
|
||||
|
||||
// 模拟 LLM 流式输出的每个阶段
|
||||
test("流式 step1: 正文", "笑话来了:", "笑话来了:", false);
|
||||
test("流式 step2: 出现 <", "笑话来了:<", "笑话来了:", true);
|
||||
test("流式 step3: <q", "笑话来了:<q", "笑话来了:", true);
|
||||
test("流式 step4: <qq", "笑话来了:<qq", "笑话来了:", true);
|
||||
test("流式 step5: <qqv", "笑话来了:<qqv", "笑话来了:", true);
|
||||
test("流式 step6: <qqvo", "笑话来了:<qqvo", "笑话来了:", true);
|
||||
test("流式 step7: <qqvoi", "笑话来了:<qqvoi", "笑话来了:", true);
|
||||
test("流式 step8: <qqvoic", "笑话来了:<qqvoic", "笑话来了:", true);
|
||||
test("流式 step9: <qqvoice", "笑话来了:<qqvoice", "笑话来了:", true);
|
||||
test("流式 step10: <qqvoice>", "笑话来了:<qqvoice>", "笑话来了:", true);
|
||||
test("流式 step11: <qqvoice>/a", "笑话来了:<qqvoice>/a.mp3", "笑话来了:", true);
|
||||
test("流式 step12: 出现 </", "笑话来了:<qqvoice>/a.mp3</", "笑话来了:", true);
|
||||
test("流式 step13: </q", "笑话来了:<qqvoice>/a.mp3</q", "笑话来了:", true);
|
||||
test("流式 step14: </qqvoice", "笑话来了:<qqvoice>/a.mp3</qqvoice", "笑话来了:", true);
|
||||
test("流式 step15: </qqvoice> 完成!", "笑话来了:<qqvoice>/a.mp3</qqvoice>", "笑话来了:<qqvoice>/a.mp3</qqvoice>", false);
|
||||
test("流式 step16: 后续文字", "笑话来了:<qqvoice>/a.mp3</qqvoice> 好听吗?", "笑话来了:<qqvoice>/a.mp3</qqvoice> 好听吗?", false);
|
||||
|
||||
// ==========================================
|
||||
// 总结
|
||||
// ==========================================
|
||||
console.log(`\n${"=".repeat(50)}`);
|
||||
console.log(`总计: ${passed + failed} 个测试, ✅ ${passed} 通过, ❌ ${failed} 失败`);
|
||||
if (failedTests.length > 0) {
|
||||
console.log(`失败用例:`);
|
||||
for (const t of failedTests) console.log(` - ${t}`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("🎉 全部通过!\n");
|
||||
}
|
||||
Reference in New Issue
Block a user