回复格式声明、共享检查与气泡归一
策略的具体实现如下。
ts
export function defineReplyFormats<Context = unknown>(options: ReplyFormatOptions<Context>) {
const { formats, checks } = options;
breakWhile(!formats?.length || !checks?.length, 'Reply formats require formats and checks');
const checkMap = new Map<string, ReplyFormatCheck>();
for (const check of checks) {
breakWhile(
!validId(check.id) || checkMap.has(check.id) || typeof check.accepts !== 'function',
'Invalid reply format check',
);
checkMap.set(check.id, Object.freeze({ ...check, accepts: check.accepts.bind(check) }));
}
const seen = new Set<string>();
const compiled: (CompiledReplyFormat & { when: (context: Context) => boolean })[] = formats.map(
(format) => {
breakWhile(
!validId(format.id) ||
seen.has(format.id) ||
typeof format.when !== 'function' ||
!format.checks?.length ||
format.checks.some((id) => !checkMap.has(id)),
'Invalid reply format',
);
seen.add(format.id);
const required = Object.freeze([...format.checks]);
return Object.freeze({
id: format.id,
description: format.description,
checks: required,
when: format.when,
inspect(bubbles: readonly string[]) {
return required.map((id) => checkMap.get(id)!).filter((check) => !check.accepts(bubbles));
},
});
},
);
return Object.freeze({
/** The template this turn uses, or `undefined` when it has no output-format requirement. */
resolve(context: Context): CompiledReplyFormat | undefined {
return compiled.find((format) => format.when(context));
},
});
}
replyFormatChecks = Object.freeze({
/**
* Every bubble is non-empty and the total (primary text included) stays within the limit.
*
* @param totalLimit - Maximum bubble count including the primary text.
* @returns The reusable `bubbles` check.
*/
bubbles(totalLimit: number): ReplyFormatCheck {
breakWhile(
!Number.isSafeInteger(totalLimit) || totalLimit < 1,
'Bubble limit must be a positive integer',
);
return Object.freeze({
id: 'bubbles',
description: `回复气泡总数不超过 ${totalLimit} 条且每条非空。`,
accepts: (bubbles: readonly string[]) =>
bubbles.length >= 1 &&
bubbles.length <= totalLimit &&
bubbles.every((text) => typeof text === 'string' && !!text.trim()),
});
},
/**
* The joined reply stays within the app's text budget.
*
* @param maxChars - Maximum total characters across bubbles.
* @returns The reusable `text-budget` check.
*/
textBudget(maxChars: number): ReplyFormatCheck {
breakWhile(
!Number.isSafeInteger(maxChars) || maxChars < 1,
'Text budget must be a positive integer',
);
return Object.freeze({
id: 'text-budget',
description: `回复正文总长不超过 ${maxChars} 字符。`,
accepts: (bubbles: readonly string[]) =>
bubbles.reduce(
(length, text) => length + (typeof text === 'string' ? text.length : 0),
0,
) <= maxChars,
});
},
})
export function mergeReplyBubbles(bubbles: readonly string[], limit: number): string[] {
breakWhile(!Number.isSafeInteger(limit) || limit < 1, 'Bubble limit must be a positive integer');
const kept = bubbles.map((text) => text.trim()).filter(Boolean);
// 超出部分按原顺序合并到最后一条允许的气泡,不改变正文内容。
if (kept.length > limit) kept.push(kept.splice(limit - 1).join('\n\n'));
return kept;
}