@xmanrui/dsh-im 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/index.js +164 -154
- package/package.json +1 -1
- package/src/channels/qq/markdown-reply.mjs +176 -0
- package/src/channels/qq/qq-bridge.mjs +44 -27
- package/src/channels/shared/harness-client.mjs +66 -13
package/package.json
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// QQ markdown 回复投递:长文尽量按结构边界切分,以 msg_type=2 发送,
|
|
2
|
+
// 平台拒绝 markdown 时逐条回退纯文本。
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CHUNK_LIMIT = 4_500;
|
|
5
|
+
const CODE_FENCE_OPEN = /^```/;
|
|
6
|
+
const GFM_TABLE_LINE = /^\|.+\|$/;
|
|
7
|
+
const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
|
|
8
|
+
const PARTIAL_REPLY_NOTICE = '回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。';
|
|
9
|
+
|
|
10
|
+
function safeSliceIndex(value, limit) {
|
|
11
|
+
let index = Math.min(limit, value.length);
|
|
12
|
+
const before = value.charCodeAt(index - 1);
|
|
13
|
+
const after = value.charCodeAt(index);
|
|
14
|
+
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
|
|
15
|
+
index -= 1;
|
|
16
|
+
}
|
|
17
|
+
return Math.max(1, index);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 按换行边界切分 Markdown 文本:
|
|
22
|
+
* - 不在代码块中间断开;
|
|
23
|
+
* - 不在 GFM 表格中间断开;
|
|
24
|
+
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
|
25
|
+
*/
|
|
26
|
+
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
27
|
+
const value = typeof text === 'string' ? text : '';
|
|
28
|
+
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
|
|
29
|
+
if (value.length <= bound) return value ? [value] : [];
|
|
30
|
+
|
|
31
|
+
const lines = value.split('\n');
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let current = '';
|
|
34
|
+
let inCodeBlock = false;
|
|
35
|
+
let tableBuffer = [];
|
|
36
|
+
|
|
37
|
+
const appendBlock = (block) => {
|
|
38
|
+
if (block.length <= bound) {
|
|
39
|
+
if (!current) {
|
|
40
|
+
current = block;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const candidate = `${current}\n${block}`;
|
|
44
|
+
if (candidate.length > bound) {
|
|
45
|
+
chunks.push(current);
|
|
46
|
+
current = block;
|
|
47
|
+
} else {
|
|
48
|
+
current = candidate;
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
|
53
|
+
if (current) {
|
|
54
|
+
chunks.push(current);
|
|
55
|
+
current = '';
|
|
56
|
+
}
|
|
57
|
+
let remaining = block;
|
|
58
|
+
while (remaining.length > bound) {
|
|
59
|
+
const index = safeSliceIndex(remaining, bound);
|
|
60
|
+
chunks.push(remaining.slice(0, index));
|
|
61
|
+
remaining = remaining.slice(index);
|
|
62
|
+
}
|
|
63
|
+
current = remaining;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const flushTable = () => {
|
|
67
|
+
if (tableBuffer.length === 0) return;
|
|
68
|
+
const block = tableBuffer.join('\n');
|
|
69
|
+
tableBuffer = [];
|
|
70
|
+
appendBlock(block);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const appendLine = (line) => {
|
|
74
|
+
let remaining = line;
|
|
75
|
+
// 超长行先硬切,保证每块不超过 bound。
|
|
76
|
+
while (remaining.length > bound) {
|
|
77
|
+
if (current) {
|
|
78
|
+
chunks.push(current);
|
|
79
|
+
current = '';
|
|
80
|
+
}
|
|
81
|
+
const index = safeSliceIndex(remaining, bound);
|
|
82
|
+
chunks.push(remaining.slice(0, index));
|
|
83
|
+
remaining = remaining.slice(index);
|
|
84
|
+
}
|
|
85
|
+
appendBlock(remaining);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (CODE_FENCE_OPEN.test(line)) {
|
|
90
|
+
flushTable();
|
|
91
|
+
if (!inCodeBlock && current) {
|
|
92
|
+
// 代码块开启:先收束当前块,让整个代码块从新块开始。
|
|
93
|
+
chunks.push(current);
|
|
94
|
+
current = '';
|
|
95
|
+
}
|
|
96
|
+
inCodeBlock = !inCodeBlock;
|
|
97
|
+
appendLine(line);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (inCodeBlock) {
|
|
101
|
+
appendLine(line);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (GFM_TABLE_LINE.test(line)) {
|
|
105
|
+
tableBuffer.push(line);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
flushTable();
|
|
109
|
+
appendLine(line);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
flushTable();
|
|
113
|
+
if (current) chunks.push(current);
|
|
114
|
+
return chunks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function nextMsgSeq() {
|
|
118
|
+
// 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
|
|
119
|
+
// 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
|
|
120
|
+
const timePart = Date.now() % 100_000_000;
|
|
121
|
+
const random = Math.floor(Math.random() * 65_536);
|
|
122
|
+
return (timePart ^ random) % 65_536;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
|
|
127
|
+
* 返回每条消息的平台响应,供调用方提取 provider message ids。
|
|
128
|
+
*/
|
|
129
|
+
export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
130
|
+
const chunks = chunkMarkdownText(text);
|
|
131
|
+
const results = [];
|
|
132
|
+
const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
|
|
133
|
+
const overflow = passiveLimit !== null && chunks.length > passiveLimit;
|
|
134
|
+
const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
|
|
135
|
+
const proactiveTarget = target?.msgId
|
|
136
|
+
? { scope: target.scope, targetId: target.targetId }
|
|
137
|
+
: target;
|
|
138
|
+
let partialNoticeSent = false;
|
|
139
|
+
|
|
140
|
+
const sendPartialNotice = async () => {
|
|
141
|
+
if (partialNoticeSent || !target?.msgId) return;
|
|
142
|
+
partialNoticeSent = true;
|
|
143
|
+
try {
|
|
144
|
+
results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
151
|
+
const deliveryTarget = overflow && index >= passiveContentCount
|
|
152
|
+
? proactiveTarget
|
|
153
|
+
: target;
|
|
154
|
+
if (typeof bot?.send === 'function') {
|
|
155
|
+
try {
|
|
156
|
+
results.push(await bot.send({
|
|
157
|
+
target: deliveryTarget,
|
|
158
|
+
msgType: 2,
|
|
159
|
+
markdown: { content: chunk },
|
|
160
|
+
extra: { msg_seq: nextMsgSeq() },
|
|
161
|
+
}));
|
|
162
|
+
continue;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
results.push(await bot.sendText(deliveryTarget, chunk));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (results.length === 0) throw error;
|
|
171
|
+
await sendPartialNotice();
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
mergeDeliveryReceipts,
|
|
43
43
|
providerMessageIdsFor,
|
|
44
44
|
} from '../shared/semantic/delivery.mjs';
|
|
45
|
+
import { sendMarkdownReply } from './markdown-reply.mjs';
|
|
45
46
|
|
|
46
47
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
47
48
|
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
@@ -644,14 +645,17 @@ export class QqHarnessBridge {
|
|
|
644
645
|
const content = hasImages
|
|
645
646
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
646
647
|
: undefined;
|
|
647
|
-
|
|
648
|
+
// QQ C2C keeps one stream bubble. Progress is collected but never submitted:
|
|
649
|
+
// some clients reject replacing an already visible stream frame, which would
|
|
650
|
+
// otherwise leave a stale progress bubble plus a separate fallback answer.
|
|
648
651
|
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
|
649
652
|
try {
|
|
650
653
|
stream = this.#bot.openStream({ target });
|
|
651
654
|
} catch (error) {
|
|
652
|
-
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using
|
|
655
|
+
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
|
|
653
656
|
}
|
|
654
657
|
}
|
|
658
|
+
const toolErrors = [];
|
|
655
659
|
let answer;
|
|
656
660
|
let artifacts = [];
|
|
657
661
|
try {
|
|
@@ -666,14 +670,15 @@ export class QqHarnessBridge {
|
|
|
666
670
|
timeoutMs: this.#replyTimeoutMs,
|
|
667
671
|
signal: this.#signal,
|
|
668
672
|
control: { owner: this, key },
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
?
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
673
|
+
progressMode: 'all',
|
|
674
|
+
onUpdate: (update) => {
|
|
675
|
+
if (update.error) {
|
|
676
|
+
const label = nonEmptyString(update.toolName)
|
|
677
|
+
? `Tool call ${update.toolName}` : 'Tool call';
|
|
678
|
+
const text = `${label}\nError: ${update.error}`;
|
|
679
|
+
toolErrors.push(text);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
677
682
|
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
678
683
|
key,
|
|
679
684
|
actor: sender,
|
|
@@ -691,31 +696,41 @@ export class QqHarnessBridge {
|
|
|
691
696
|
]);
|
|
692
697
|
}
|
|
693
698
|
this.#signal?.throwIfAborted();
|
|
694
|
-
const
|
|
699
|
+
const answerText = answerTextForDelivery(answer, artifacts);
|
|
700
|
+
const displayAnswer = toolErrors.length > 0
|
|
701
|
+
? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
|
|
702
|
+
: answerText;
|
|
695
703
|
let textReceipt = null;
|
|
696
704
|
let textSendError = null;
|
|
697
705
|
try {
|
|
706
|
+
let streamFinished = false;
|
|
698
707
|
if (stream) {
|
|
699
708
|
try {
|
|
700
709
|
await stream.update(displayAnswer);
|
|
701
|
-
await stream.complete();
|
|
702
710
|
streamFinished = true;
|
|
703
711
|
textReceipt = createDeliveryReceipt({
|
|
704
712
|
deliveryId: messageId,
|
|
705
713
|
presentation: 'qq-text',
|
|
706
714
|
providerMessageIds: providerMessageIdsFor(stream),
|
|
707
715
|
});
|
|
716
|
+
try {
|
|
717
|
+
await stream.complete();
|
|
718
|
+
} catch (error) {
|
|
719
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
|
|
720
|
+
}
|
|
708
721
|
} catch (error) {
|
|
709
722
|
stream.cancel?.();
|
|
710
|
-
this.#logger.warn?.('[dsh-im:qq] QQ stream
|
|
723
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
|
|
711
724
|
}
|
|
712
725
|
}
|
|
713
726
|
if (!streamFinished) {
|
|
714
|
-
const
|
|
727
|
+
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
|
|
728
|
+
logger: this.#logger,
|
|
729
|
+
});
|
|
715
730
|
textReceipt = createDeliveryReceipt({
|
|
716
731
|
deliveryId: messageId,
|
|
717
732
|
presentation: 'qq-text',
|
|
718
|
-
providerMessageIds: providerMessageIdsFor(
|
|
733
|
+
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
|
|
719
734
|
});
|
|
720
735
|
}
|
|
721
736
|
} catch (error) {
|
|
@@ -736,22 +751,24 @@ export class QqHarnessBridge {
|
|
|
736
751
|
return delivery.receipt;
|
|
737
752
|
} catch (error) {
|
|
738
753
|
if (error?.code === 'turn-stopped') {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
749
|
-
}
|
|
754
|
+
try {
|
|
755
|
+
stream?.cancel?.();
|
|
756
|
+
} catch (streamError) {
|
|
757
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
await this.#bot.sendText(target, '已停止。');
|
|
761
|
+
} catch (sendError) {
|
|
762
|
+
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
750
763
|
}
|
|
751
764
|
await this.#state.markSeen(messageId);
|
|
752
765
|
return;
|
|
753
766
|
}
|
|
754
|
-
|
|
767
|
+
try {
|
|
768
|
+
stream?.cancel?.();
|
|
769
|
+
} catch (streamError) {
|
|
770
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
|
|
771
|
+
}
|
|
755
772
|
if (this.#signal?.aborted) return;
|
|
756
773
|
this.#status.lastError = error?.message ?? String(error);
|
|
757
774
|
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
|
@@ -255,6 +255,21 @@ function assistantMessageText(event) {
|
|
|
255
255
|
.trim();
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
function nonEmptyText(value) {
|
|
259
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Flatten a tool/result error payload into a displayable one-line reason. */
|
|
263
|
+
function toolResultErrorText(error) {
|
|
264
|
+
if (!error || typeof error !== 'object') return null;
|
|
265
|
+
const message = nonEmptyText(error.message);
|
|
266
|
+
if (message) return message;
|
|
267
|
+
const name = nonEmptyText(error.name);
|
|
268
|
+
const code = nonEmptyText(error.code);
|
|
269
|
+
if (name || code) return [name ?? 'Error', code].filter(Boolean).join(': ');
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
258
273
|
function consumeInteractionOwnership(ownership, entries) {
|
|
259
274
|
const ordered = [...entries]
|
|
260
275
|
.map((entry) => entry?.event ?? entry)
|
|
@@ -325,6 +340,8 @@ export class HarnessReplyTracker {
|
|
|
325
340
|
#latestText = '';
|
|
326
341
|
#finished = false;
|
|
327
342
|
#reason = null;
|
|
343
|
+
#toolNames = new Map();
|
|
344
|
+
#lastToolName = null;
|
|
328
345
|
|
|
329
346
|
constructor({ promptRpcId, afterSeq = -1 }) {
|
|
330
347
|
this.#promptRpcId = promptRpcId;
|
|
@@ -351,8 +368,20 @@ export class HarnessReplyTracker {
|
|
|
351
368
|
return this.#targetTurn;
|
|
352
369
|
}
|
|
353
370
|
|
|
354
|
-
|
|
355
|
-
|
|
371
|
+
consumeAll(entries) {
|
|
372
|
+
const updates = [];
|
|
373
|
+
// 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
|
|
374
|
+
// 让消费方能按顺序看到每个工具调用与结果。
|
|
375
|
+
const pushUpdate = (update) => {
|
|
376
|
+
if (update.type === 'text' && updates.length > 0) {
|
|
377
|
+
const last = updates[updates.length - 1];
|
|
378
|
+
if (last.type === 'text') {
|
|
379
|
+
updates[updates.length - 1] = update;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
updates.push(update);
|
|
384
|
+
};
|
|
356
385
|
const ordered = [...entries]
|
|
357
386
|
.map((entry) => entry?.event ?? entry)
|
|
358
387
|
.filter(Boolean)
|
|
@@ -394,7 +423,7 @@ export class HarnessReplyTracker {
|
|
|
394
423
|
.trim();
|
|
395
424
|
if (text && text !== this.#latestText) {
|
|
396
425
|
this.#latestText = text;
|
|
397
|
-
|
|
426
|
+
pushUpdate({ type: 'text', text });
|
|
398
427
|
}
|
|
399
428
|
continue;
|
|
400
429
|
}
|
|
@@ -403,18 +432,38 @@ export class HarnessReplyTracker {
|
|
|
403
432
|
const text = assistantMessageText(event);
|
|
404
433
|
if (text && text !== this.#latestText) {
|
|
405
434
|
this.#latestText = text;
|
|
406
|
-
|
|
435
|
+
pushUpdate({ type: 'text', text });
|
|
407
436
|
}
|
|
408
437
|
continue;
|
|
409
438
|
}
|
|
410
439
|
|
|
411
440
|
if (event.type === 'tool/call') {
|
|
412
|
-
|
|
441
|
+
const name = nonEmptyText(event.data?.name) ?? '工具';
|
|
442
|
+
const callId = nonEmptyText(event.data?.callId)
|
|
443
|
+
?? nonEmptyText(event.data?.subCallId);
|
|
444
|
+
if (callId) this.#toolNames.set(callId, name);
|
|
445
|
+
this.#lastToolName = name;
|
|
446
|
+
pushUpdate({ type: 'tool', name, ...(callId ? { callId } : {}) });
|
|
413
447
|
} else if (event.type === 'tool/result') {
|
|
414
|
-
|
|
448
|
+
const callId = nonEmptyText(event.data?.message?.source?.callId)
|
|
449
|
+
?? nonEmptyText(event.data?.callId)
|
|
450
|
+
?? nonEmptyText(event.data?.subCallId);
|
|
451
|
+
const toolName = (callId ? this.#toolNames.get(callId) : null)
|
|
452
|
+
?? this.#lastToolName;
|
|
453
|
+
const error = toolResultErrorText(event.data?.error);
|
|
454
|
+
pushUpdate({
|
|
455
|
+
type: 'status',
|
|
456
|
+
text: '正在整理结果…',
|
|
457
|
+
...(toolName ? { toolName } : {}),
|
|
458
|
+
...(error ? { error } : {}),
|
|
459
|
+
});
|
|
415
460
|
}
|
|
416
461
|
}
|
|
417
|
-
return
|
|
462
|
+
return updates;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
consume(entries) {
|
|
466
|
+
return this.consumeAll(entries).at(-1) ?? null;
|
|
418
467
|
}
|
|
419
468
|
}
|
|
420
469
|
|
|
@@ -1036,6 +1085,7 @@ export class HarnessClient {
|
|
|
1036
1085
|
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
1037
1086
|
const signal = options.signal;
|
|
1038
1087
|
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
|
|
1088
|
+
const progressMode = options.progressMode === 'all' ? 'all' : 'latest';
|
|
1039
1089
|
const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
|
|
1040
1090
|
const onInteraction = typeof options.onInteraction === 'function'
|
|
1041
1091
|
? options.onInteraction
|
|
@@ -1189,12 +1239,15 @@ export class HarnessClient {
|
|
|
1189
1239
|
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
|
|
1190
1240
|
if (!wasActive && ownership.active) ownership.reconnect?.();
|
|
1191
1241
|
}
|
|
1192
|
-
const
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1242
|
+
const updates = tracker.consumeAll(history.events ?? []);
|
|
1243
|
+
if (onUpdate) {
|
|
1244
|
+
const visibleUpdates = progressMode === 'all' ? updates : updates.slice(-1);
|
|
1245
|
+
for (const update of visibleUpdates) {
|
|
1246
|
+
try {
|
|
1247
|
+
await onUpdate(update);
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
|
|
1250
|
+
}
|
|
1198
1251
|
}
|
|
1199
1252
|
}
|
|
1200
1253
|
if (!tracker.finished) continue;
|