2026-02-08 12:22:45 +08:00
|
|
|
|
// ============================================================================
|
2026-02-11 22:01:02 +08:00
|
|
|
|
// atom-extraction.js - L0 场景锚点提取(v2 - 场景摘要 + 图结构)
|
|
|
|
|
|
//
|
|
|
|
|
|
// 设计依据:
|
|
|
|
|
|
// - BGE-M3 (BAAI, 2024): 自然语言段落检索精度最高 → semantic = 纯自然语言
|
|
|
|
|
|
// - Interpersonal Circumplex (Kiesler, 1983): 权力轴+情感轴 → dynamics 枚举
|
|
|
|
|
|
// - Labov Narrative Structure (1972): 叙事功能轴 → dynamics 枚举补充
|
|
|
|
|
|
// - TransE (Bordes, 2013): s/t/r 三元组方向性 → edges 格式
|
|
|
|
|
|
//
|
|
|
|
|
|
// 每楼层 1-2 个场景锚点(非碎片原子),60-100 字场景摘要
|
2026-02-06 11:22:02 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
import { callLLM, parseJson } from './llm-service.js';
|
|
|
|
|
|
import { xbLog } from '../../../../core/debug-core.js';
|
|
|
|
|
|
import { filterText } from '../utils/text-filter.js';
|
|
|
|
|
|
|
|
|
|
|
|
const MODULE_ID = 'atom-extraction';
|
|
|
|
|
|
|
|
|
|
|
|
const CONCURRENCY = 10;
|
|
|
|
|
|
const RETRY_COUNT = 2;
|
|
|
|
|
|
const RETRY_DELAY = 500;
|
|
|
|
|
|
const DEFAULT_TIMEOUT = 20000;
|
2026-02-08 12:22:45 +08:00
|
|
|
|
const STAGGER_DELAY = 80;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
|
|
|
|
|
let batchCancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
export function cancelBatchExtraction() {
|
|
|
|
|
|
batchCancelled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isBatchCancelled() {
|
|
|
|
|
|
return batchCancelled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
// ============================================================================
|
2026-02-11 22:01:02 +08:00
|
|
|
|
// dynamics 封闭枚举(8 个标签,两轴四象限 + 叙事轴)
|
2026-02-08 12:22:45 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
const VALID_DYNAMICS = new Set([
|
|
|
|
|
|
// 权力轴 (Interpersonal Circumplex: Dominance-Submission)
|
|
|
|
|
|
'支配', // 控制、命令、审视、威慑、主导
|
|
|
|
|
|
'让渡', // 顺从、服从、屈服、被动、配合
|
|
|
|
|
|
|
|
|
|
|
|
// 情感轴 (Interpersonal Circumplex: Hostility-Friendliness)
|
|
|
|
|
|
'亲密', // 温柔、关怀、依赖、信任、连接
|
|
|
|
|
|
'敌意', // 对抗、拒绝、攻击、嘲讽、排斥
|
|
|
|
|
|
|
|
|
|
|
|
// 叙事轴 (Labov Narrative Structure)
|
|
|
|
|
|
'揭示', // 真相、发现、告白、暴露、秘密
|
|
|
|
|
|
'决意', // 选择、承诺、放弃、宣言、转折
|
|
|
|
|
|
'张力', // 悬念、对峙、暗涌、不安、等待
|
|
|
|
|
|
'丧失', // 分离、死亡、破碎、遗憾、崩塌
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// L0 提取 Prompt
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
const SYSTEM_PROMPT = `你是场景摘要器。从一轮对话中提取1-2个场景锚点,用于语义检索和关系追踪。
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
2026-02-06 15:08:20 +08:00
|
|
|
|
输入格式:
|
|
|
|
|
|
<round>
|
2026-02-08 12:22:45 +08:00
|
|
|
|
<user name="用户名">...</user>
|
2026-02-13 14:30:56 +08:00
|
|
|
|
<assistant>...</assistant>
|
2026-02-06 15:08:20 +08:00
|
|
|
|
</round>
|
|
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
只输出严格JSON:
|
2026-02-11 22:01:02 +08:00
|
|
|
|
{"anchors":[
|
|
|
|
|
|
{
|
|
|
|
|
|
"scene": "60-100字完整场景描述",
|
|
|
|
|
|
"who": ["角色名1","角色名2"],
|
|
|
|
|
|
"edges": [{"s":"施事方","t":"受事方","r":"互动行为"}],
|
|
|
|
|
|
"dynamics": ["标签"],
|
|
|
|
|
|
"where": "地点"
|
|
|
|
|
|
}
|
2026-02-08 12:22:45 +08:00
|
|
|
|
]}
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
## scene 写法
|
|
|
|
|
|
- 纯自然语言,像旁白或日记,不要任何标签/标记/枚举值
|
|
|
|
|
|
- 必须包含:角色名、动作、情感氛围、关键细节
|
|
|
|
|
|
- 读者只看 scene 就能复原这一幕
|
|
|
|
|
|
- 60-100字,信息密集但流畅
|
2026-02-08 12:22:45 +08:00
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
## who
|
|
|
|
|
|
- 参与互动的角色正式名称,不用代词或别称
|
2026-02-13 14:30:56 +08:00
|
|
|
|
- 只从正文内容中识别角色名,不要把标签名(如 user、assistant)当作角色
|
2026-02-08 12:22:45 +08:00
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
## edges(关系三元组)
|
|
|
|
|
|
- s=施事方 t=受事方 r=互动行为(10-15字)
|
|
|
|
|
|
- 每个锚点 1-3 条
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
## dynamics(封闭枚举,选0-2个)
|
|
|
|
|
|
权力轴:支配(控制/命令/审视) | 让渡(顺从/服从/屈服)
|
|
|
|
|
|
情感轴:亲密(温柔/信任/连接) | 敌意(对抗/拒绝/攻击)
|
|
|
|
|
|
叙事轴:揭示(真相/秘密) | 决意(选择/承诺) | 张力(对峙/不安) | 丧失(分离/破碎)
|
|
|
|
|
|
纯日常无明显模式时 dynamics 为 []
|
2026-02-08 12:22:45 +08:00
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
## where
|
|
|
|
|
|
- 场景地点,无明确地点时空字符串
|
|
|
|
|
|
|
|
|
|
|
|
## 数量规则
|
|
|
|
|
|
- 最多2个。1个够时不凑2个
|
|
|
|
|
|
- 明显场景切换(地点/时间/对象变化)时才2个
|
|
|
|
|
|
- 同一场景不拆分
|
|
|
|
|
|
- 无角色互动时返回 {"anchors":[]}
|
|
|
|
|
|
|
|
|
|
|
|
## 示例
|
|
|
|
|
|
输入:艾拉在火山口举起圣剑刺穿古龙心脏,龙血溅满她的铠甲,她跪倒在地痛哭
|
|
|
|
|
|
输出:
|
|
|
|
|
|
{"anchors":[{"scene":"火山口上艾拉举起圣剑刺穿古龙的心脏,龙血溅满铠甲,古龙轰然倒地,艾拉跪倒在滚烫的岩石上痛哭,完成了她不得不做的弑杀","who":["艾拉","古龙"],"edges":[{"s":"艾拉","t":"古龙","r":"以圣剑刺穿心脏"}],"dynamics":["决意","丧失"],"where":"火山口"}]}`;
|
|
|
|
|
|
|
|
|
|
|
|
const JSON_PREFILL = '{"anchors":[';
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 睡眠工具
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-02-06 11:22:02 +08:00
|
|
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 清洗与构建
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清洗 dynamics 标签,只保留合法枚举值
|
|
|
|
|
|
* @param {string[]} raw
|
|
|
|
|
|
* @returns {string[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sanitizeDynamics(raw) {
|
|
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
|
|
return raw
|
|
|
|
|
|
.map(d => String(d || '').trim())
|
|
|
|
|
|
.filter(d => VALID_DYNAMICS.has(d))
|
|
|
|
|
|
.slice(0, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清洗 edges 三元组
|
|
|
|
|
|
* @param {object[]} raw
|
|
|
|
|
|
* @returns {object[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sanitizeEdges(raw) {
|
|
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
|
|
return raw
|
|
|
|
|
|
.filter(e => e && typeof e === 'object')
|
|
|
|
|
|
.map(e => ({
|
|
|
|
|
|
s: String(e.s || '').trim(),
|
|
|
|
|
|
t: String(e.t || '').trim(),
|
|
|
|
|
|
r: String(e.r || '').trim().slice(0, 30),
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter(e => e.s && e.t && e.r)
|
|
|
|
|
|
.slice(0, 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清洗 who 列表
|
|
|
|
|
|
* @param {string[]} raw
|
|
|
|
|
|
* @returns {string[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sanitizeWho(raw) {
|
|
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
return raw
|
|
|
|
|
|
.map(w => String(w || '').trim())
|
|
|
|
|
|
.filter(w => {
|
|
|
|
|
|
if (!w || w.length < 1 || seen.has(w)) return false;
|
|
|
|
|
|
seen.add(w);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
})
|
|
|
|
|
|
.slice(0, 6);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将解析后的 anchor 转换为 atom 存储对象
|
|
|
|
|
|
*
|
|
|
|
|
|
* semantic = scene(纯自然语言,直接用于 embedding)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {object} anchor - LLM 输出的 anchor 对象
|
|
|
|
|
|
* @param {number} aiFloor - AI 消息楼层号
|
|
|
|
|
|
* @param {number} idx - 同楼层序号(0 或 1)
|
|
|
|
|
|
* @returns {object|null} atom 对象
|
|
|
|
|
|
*/
|
|
|
|
|
|
function anchorToAtom(anchor, aiFloor, idx) {
|
|
|
|
|
|
const scene = String(anchor.scene || '').trim();
|
|
|
|
|
|
if (!scene) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// scene 过短(< 15 字)可能是噪音
|
|
|
|
|
|
if (scene.length < 15) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const who = sanitizeWho(anchor.who);
|
|
|
|
|
|
const edges = sanitizeEdges(anchor.edges);
|
|
|
|
|
|
const dynamics = sanitizeDynamics(anchor.dynamics);
|
|
|
|
|
|
const where = String(anchor.where || '').trim();
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
atomId: `atom-${aiFloor}-${idx}`,
|
|
|
|
|
|
floor: aiFloor,
|
|
|
|
|
|
source: 'ai',
|
|
|
|
|
|
|
|
|
|
|
|
// ═══ 检索层(embedding 的唯一入口) ═══
|
|
|
|
|
|
semantic: scene,
|
|
|
|
|
|
|
|
|
|
|
|
// ═══ 图结构层(扩散的 key) ═══
|
|
|
|
|
|
who,
|
|
|
|
|
|
edges,
|
|
|
|
|
|
dynamics,
|
|
|
|
|
|
where,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 单轮提取(带重试)
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-02-06 11:22:02 +08:00
|
|
|
|
async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, options = {}) {
|
|
|
|
|
|
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
|
|
|
|
|
|
|
|
|
|
if (!aiMessage?.mes?.trim()) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
const userName = userMessage?.name || '用户';
|
|
|
|
|
|
|
|
|
|
|
|
if (userMessage?.mes?.trim()) {
|
|
|
|
|
|
const userText = filterText(userMessage.mes);
|
2026-02-06 15:08:20 +08:00
|
|
|
|
parts.push(`<user name="${userName}">\n${userText}\n</user>`);
|
2026-02-06 11:22:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const aiText = filterText(aiMessage.mes);
|
2026-02-13 14:30:56 +08:00
|
|
|
|
parts.push(`<assistant>\n${aiText}\n</assistant>`);
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
2026-02-06 15:08:20 +08:00
|
|
|
|
const input = `<round>\n${parts.join('\n')}\n</round>`;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
|
|
|
|
|
if (batchCancelled) return [];
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await callLLM([
|
|
|
|
|
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
|
|
|
|
{ role: 'user', content: input },
|
2026-02-08 12:22:45 +08:00
|
|
|
|
{ role: 'assistant', content: JSON_PREFILL },
|
2026-02-06 11:22:02 +08:00
|
|
|
|
], {
|
2026-02-11 22:01:02 +08:00
|
|
|
|
temperature: 0.3,
|
|
|
|
|
|
max_tokens: 600,
|
2026-02-06 11:22:02 +08:00
|
|
|
|
timeout,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-06 15:08:20 +08:00
|
|
|
|
const rawText = String(response || '');
|
|
|
|
|
|
if (!rawText.trim()) {
|
2026-02-06 11:22:02 +08:00
|
|
|
|
if (attempt < RETRY_COUNT) {
|
|
|
|
|
|
await sleep(RETRY_DELAY);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-02-06 15:08:20 +08:00
|
|
|
|
return null;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
const fullJson = JSON_PREFILL + rawText;
|
|
|
|
|
|
|
2026-02-06 11:22:02 +08:00
|
|
|
|
let parsed;
|
|
|
|
|
|
try {
|
2026-02-08 12:22:45 +08:00
|
|
|
|
parsed = parseJson(fullJson);
|
2026-02-06 11:22:02 +08:00
|
|
|
|
} catch (e) {
|
2026-02-11 22:01:02 +08:00
|
|
|
|
xbLog.warn(MODULE_ID, `floor ${aiFloor} JSON解析失败 (attempt ${attempt})`);
|
2026-02-06 11:22:02 +08:00
|
|
|
|
if (attempt < RETRY_COUNT) {
|
|
|
|
|
|
await sleep(RETRY_DELAY);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-02-06 15:08:20 +08:00
|
|
|
|
return null;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
// 兼容:优先 anchors,回退 atoms
|
|
|
|
|
|
const rawAnchors = parsed?.anchors || parsed?.atoms;
|
|
|
|
|
|
if (!rawAnchors || !Array.isArray(rawAnchors)) {
|
2026-02-06 11:22:02 +08:00
|
|
|
|
if (attempt < RETRY_COUNT) {
|
|
|
|
|
|
await sleep(RETRY_DELAY);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-02-06 15:08:20 +08:00
|
|
|
|
return null;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:01:02 +08:00
|
|
|
|
// 转换为 atom 存储格式(最多 2 个)
|
|
|
|
|
|
const atoms = rawAnchors
|
|
|
|
|
|
.slice(0, 2)
|
|
|
|
|
|
.map((a, idx) => anchorToAtom(a, aiFloor, idx))
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
return atoms;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
2026-02-06 15:08:20 +08:00
|
|
|
|
if (batchCancelled) return null;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
|
|
|
|
|
if (attempt < RETRY_COUNT) {
|
|
|
|
|
|
await sleep(RETRY_DELAY * (attempt + 1));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
xbLog.error(MODULE_ID, `floor ${aiFloor} 失败`, e);
|
2026-02-06 15:08:20 +08:00
|
|
|
|
return null;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 15:08:20 +08:00
|
|
|
|
return null;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function extractAtomsForRound(userMessage, aiMessage, aiFloor, options = {}) {
|
|
|
|
|
|
return extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, options);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 批量提取
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-02-06 11:22:02 +08:00
|
|
|
|
export async function batchExtractAtoms(chat, onProgress) {
|
|
|
|
|
|
if (!chat?.length) return [];
|
|
|
|
|
|
|
|
|
|
|
|
batchCancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
const pairs = [];
|
|
|
|
|
|
for (let i = 0; i < chat.length; i++) {
|
|
|
|
|
|
if (!chat[i].is_user) {
|
|
|
|
|
|
const userMsg = (i > 0 && chat[i - 1]?.is_user) ? chat[i - 1] : null;
|
|
|
|
|
|
pairs.push({ userMsg, aiMsg: chat[i], aiFloor: i });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!pairs.length) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const allAtoms = [];
|
|
|
|
|
|
let completed = 0;
|
|
|
|
|
|
let failed = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < pairs.length; i += CONCURRENCY) {
|
2026-02-08 12:22:45 +08:00
|
|
|
|
if (batchCancelled) break;
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
|
|
|
|
|
const batch = pairs.slice(i, i + CONCURRENCY);
|
|
|
|
|
|
|
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
|
const promises = batch.map((pair, idx) => (async () => {
|
|
|
|
|
|
await sleep(idx * STAGGER_DELAY);
|
|
|
|
|
|
|
|
|
|
|
|
if (batchCancelled) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-08 12:22:45 +08:00
|
|
|
|
const atoms = await extractAtomsForRoundWithRetry(
|
|
|
|
|
|
pair.userMsg,
|
|
|
|
|
|
pair.aiMsg,
|
|
|
|
|
|
pair.aiFloor,
|
|
|
|
|
|
{ timeout: DEFAULT_TIMEOUT }
|
|
|
|
|
|
);
|
2026-02-06 11:22:02 +08:00
|
|
|
|
if (atoms?.length) {
|
|
|
|
|
|
allAtoms.push(...atoms);
|
2026-02-08 12:22:45 +08:00
|
|
|
|
} else if (atoms === null) {
|
2026-02-06 11:22:02 +08:00
|
|
|
|
failed++;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
failed++;
|
|
|
|
|
|
}
|
|
|
|
|
|
completed++;
|
|
|
|
|
|
onProgress?.(completed, pairs.length, failed);
|
|
|
|
|
|
})());
|
|
|
|
|
|
await Promise.all(promises);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const promises = batch.map(pair =>
|
2026-02-08 12:22:45 +08:00
|
|
|
|
extractAtomsForRoundWithRetry(
|
|
|
|
|
|
pair.userMsg,
|
|
|
|
|
|
pair.aiMsg,
|
|
|
|
|
|
pair.aiFloor,
|
|
|
|
|
|
{ timeout: DEFAULT_TIMEOUT }
|
|
|
|
|
|
)
|
2026-02-06 11:22:02 +08:00
|
|
|
|
.then(atoms => {
|
|
|
|
|
|
if (batchCancelled) return;
|
|
|
|
|
|
if (atoms?.length) {
|
|
|
|
|
|
allAtoms.push(...atoms);
|
2026-02-08 12:22:45 +08:00
|
|
|
|
} else if (atoms === null) {
|
2026-02-06 11:22:02 +08:00
|
|
|
|
failed++;
|
|
|
|
|
|
}
|
|
|
|
|
|
completed++;
|
|
|
|
|
|
onProgress?.(completed, pairs.length, failed);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (batchCancelled) return;
|
|
|
|
|
|
failed++;
|
|
|
|
|
|
completed++;
|
|
|
|
|
|
onProgress?.(completed, pairs.length, failed);
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(promises);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (i + CONCURRENCY < pairs.length && !batchCancelled) {
|
|
|
|
|
|
await sleep(30);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 12:22:45 +08:00
|
|
|
|
xbLog.info(MODULE_ID, `批量提取完成: ${allAtoms.length} atoms, ${failed} 失败`);
|
2026-02-06 11:22:02 +08:00
|
|
|
|
|
|
|
|
|
|
return allAtoms;
|
|
|
|
|
|
}
|