diff --git a/modules/story-summary/generate/generator.js b/modules/story-summary/generate/generator.js index d769d29..0a80e9b 100644 --- a/modules/story-summary/generate/generator.js +++ b/modules/story-summary/generate/generator.js @@ -50,7 +50,7 @@ function sanitizeFacts(parsed) { }; if (isRel && item.trend) { - const validTrends = ['??', '??', '??', '??', '??', '??', '??']; + const validTrends = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融']; if (validTrends.includes(item.trend)) { fact.trend = item.trend; } diff --git a/modules/story-summary/generate/llm.js b/modules/story-summary/generate/llm.js index 9581243..227d1e5 100644 --- a/modules/story-summary/generate/llm.js +++ b/modules/story-summary/generate/llm.js @@ -100,19 +100,25 @@ Acknowledged. Now reviewing the incremental summarization specifications: ├─ progress: 0.0 to 1.0 └─ newMoment: 仅记录本次新增的关键时刻 -[Fact Tracking - SPO ???] -?? ??: ?? & ??????? -?? ??: ?????????????????? -?? SPO ??: -? s: ??????/???? -? p: ?????????????? -? o: ??? -?? KV ??: s+p ?????????? -?? isState ????????: -? true = ????????????/??/??/??? -? false = ?????????????? -?? trend: ?????????/??/??/??/??/??/??? -?? retracted: true ??????? +[Fact Tracking - SPO / World Facts] +We maintain a small "world state" as SPO triples. +Each update is a JSON object: {s, p, o, isState, trend?, retracted?} + +Core rules: +1) Keyed by (s + p). If a new update has the same (s+p), it overwrites the previous value. +2) Only output facts that are NEW or CHANGED in the new dialogue. Do NOT repeat unchanged facts. +3) isState meaning: + - isState: true -> core constraints that must stay stable and should NEVER be auto-deleted + (identity, location, life/death, ownership, relationship status, binding rules) + - isState: false -> non-core facts / soft memories that may be pruned by capacity limits later +4) Relationship facts: + - Use predicate format: "对X的看法" (X is the target person) + - trend is required for relationship facts, one of: + 破裂 | 厌恶 | 反感 | 陌生 | 投缘 | 亲密 | 交融 +5) Retraction (deletion): + - To delete a fact, output: {s, p, retracted: true} +6) Predicate normalization: + - Reuse existing predicates whenever possible, avoid inventing synonyms. Ready to process incremental summary requests with strict deduplication.`, @@ -432,4 +438,4 @@ export async function generateSummary(options) { console.groupEnd(); return rawOutput; -} \ No newline at end of file +} diff --git a/modules/story-summary/story-summary-ui.js b/modules/story-summary/story-summary-ui.js index aa5aafa..53551aa 100644 --- a/modules/story-summary/story-summary-ui.js +++ b/modules/story-summary/story-summary-ui.js @@ -360,20 +360,20 @@ function initVectorUI() { }; $('btn-clear-vectors').onclick = () => { - if (confirm('?????????')) postMsg('VECTOR_CLEAR'); + if (confirm('确定清空所有向量数据?')) postMsg('VECTOR_CLEAR'); }; $('btn-cancel-vectors').onclick = () => postMsg('VECTOR_CANCEL_GENERATE'); $('btn-export-vectors').onclick = () => { $('btn-export-vectors').disabled = true; - $('vector-io-status').textContent = '???...'; + $('vector-io-status').textContent = '导出中...'; postMsg('VECTOR_EXPORT'); }; $('btn-import-vectors').onclick = () => { $('btn-import-vectors').disabled = true; - $('vector-io-status').textContent = '???...'; + $('vector-io-status').textContent = '导入中...'; postMsg('VECTOR_IMPORT_PICK'); }; diff --git a/modules/story-summary/vector/llm/atom-extraction.js b/modules/story-summary/vector/llm/atom-extraction.js index f386970..32826b1 100644 --- a/modules/story-summary/vector/llm/atom-extraction.js +++ b/modules/story-summary/vector/llm/atom-extraction.js @@ -26,7 +26,13 @@ export function isBatchCancelled() { const SYSTEM_PROMPT = `你是叙事锚点提取器。从一轮对话(用户发言+角色回复)中提取4-8个关键锚点。 -只输出JSON: +输入格式: + + ... + ... + + +只输出严格JSON(不要解释,不要前后多余文字): {"atoms":[{"t":"类型","s":"主体","v":"值","f":"来源"}]} 类型(t): @@ -72,13 +78,13 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op if (userMessage?.mes?.trim()) { const userText = filterText(userMessage.mes); - parts.push(`【用户:${userName}】\n${userText}`); + parts.push(`\n${userText}\n`); } const aiText = filterText(aiMessage.mes); - parts.push(`【角色:${aiName}】\n${aiText}`); + parts.push(`\n${aiText}\n`); - const input = parts.join('\n\n---\n\n'); + const input = `\n${parts.join('\n')}\n`; xbLog.info(MODULE_ID, `floor ${aiFloor} 发送输入 len=${input.length}`); @@ -89,43 +95,46 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op const response = await callLLM([ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: input }, + { role: 'assistant', content: '收到,开始提取并仅输出 JSON。' }, ], { temperature: 0.2, max_tokens: 500, timeout, }); - if (!response || !String(response).trim()) { + const rawText = String(response || ''); + if (!rawText.trim()) { xbLog.warn(MODULE_ID, `floor ${aiFloor} 解析失败:响应为空`); if (attempt < RETRY_COUNT) { await sleep(RETRY_DELAY); continue; } - return []; + return null; } let parsed; try { - parsed = parseJson(response); + parsed = parseJson(rawText); } catch (e) { xbLog.warn(MODULE_ID, `floor ${aiFloor} 解析失败:JSON 异常`); if (attempt < RETRY_COUNT) { await sleep(RETRY_DELAY); continue; } - return []; + return null; } if (!parsed?.atoms || !Array.isArray(parsed.atoms)) { + xbLog.warn(MODULE_ID, `floor ${aiFloor} atoms 缺失,raw="${rawText.slice(0, 300)}"`); xbLog.warn(MODULE_ID, `floor ${aiFloor} 解析失败:atoms 缺失`); if (attempt < RETRY_COUNT) { await sleep(RETRY_DELAY); continue; } - return []; + return null; } - return parsed.atoms + const filtered = parsed.atoms .filter(a => a?.t && a?.v) .map((a, idx) => ({ atomId: `atom-${aiFloor}-${idx}`, @@ -136,9 +145,13 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op source: a.f === 'u' ? 'user' : 'ai', semantic: buildSemantic(a, userName, aiName), })); + if (!filtered.length) { + xbLog.warn(MODULE_ID, `floor ${aiFloor} atoms 为空,raw="${rawText.slice(0, 300)}"`); + } + return filtered; } catch (e) { - if (batchCancelled) return []; + if (batchCancelled) return null; if (attempt < RETRY_COUNT) { xbLog.warn(MODULE_ID, `floor ${aiFloor} 第${attempt + 1}次失败,重试...`, e?.message); @@ -146,11 +159,11 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op continue; } xbLog.error(MODULE_ID, `floor ${aiFloor} 失败`, e); - return []; + return null; } } - return []; + return null; } /** diff --git a/modules/story-summary/vector/llm/llm-service.js b/modules/story-summary/vector/llm/llm-service.js index a4c808c..a870b0d 100644 --- a/modules/story-summary/vector/llm/llm-service.js +++ b/modules/story-summary/vector/llm/llm-service.js @@ -1,10 +1,12 @@ -// ═══════════════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════════════════════ // vector/llm/llm-service.js // ═══════════════════════════════════════════════════════════════════════════ - import { xbLog } from '../../../../core/debug-core.js'; +import { getVectorConfig } from '../../data/config.js'; const MODULE_ID = 'vector-llm-service'; +const SILICONFLOW_API_URL = 'https://api.siliconflow.cn'; +const DEFAULT_L0_MODEL = 'Qwen/Qwen3-8B'; // 唯一 ID 计数器 let callCounter = 0; @@ -36,11 +38,17 @@ export async function callLLM(messages, options = {}) { } = options; const mod = getStreamingModule(); - if (!mod) throw new Error('生成模块未加载'); + if (!mod) throw new Error('Streaming module not ready'); + + const cfg = getVectorConfig(); + const apiKey = cfg?.online?.key || ''; + if (!apiKey) { + throw new Error('L0 requires siliconflow API key'); + } const top64 = b64UrlEncode(JSON.stringify(messages)); - // ★ 每次调用用唯一 ID,避免 session 冲突 + // 每次调用用唯一 ID,避免 session 冲突 const uniqueId = generateUniqueId('l0'); const args = { @@ -50,6 +58,10 @@ export async function callLLM(messages, options = {}) { id: uniqueId, temperature: String(temperature), max_tokens: String(max_tokens), + api: 'openai', + apiurl: SILICONFLOW_API_URL, + apipassword: apiKey, + model: DEFAULT_L0_MODEL, }; try { diff --git a/modules/story-summary/vector/pipeline/state-integration.js b/modules/story-summary/vector/pipeline/state-integration.js index b07e712..5e7a132 100644 --- a/modules/story-summary/vector/pipeline/state-integration.js +++ b/modules/story-summary/vector/pipeline/state-integration.js @@ -142,7 +142,11 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) { try { const atoms = await extractAtomsForRound(pair.userMsg, pair.aiMsg, floor, { timeout: 20000 }); - if (!atoms?.length) { + if (atoms == null) { + throw new Error('llm_failed'); + } + + if (!atoms.length) { setL0FloorStatus(floor, { status: 'empty', reason: 'llm_empty', atoms: 0 }); } else { atoms.forEach(a => a.chatId = chatId); diff --git a/modules/variables/variables-core.js b/modules/variables/variables-core.js index 36bc71f..69c81d9 100644 --- a/modules/variables/variables-core.js +++ b/modules/variables/variables-core.js @@ -1627,7 +1627,7 @@ function rollbackToPreviousOf(messageId) { const prevId = id - 1; if (prevId < 0) return; - // ???? 1.0 ??????? + // 1.0: restore from snapshot if available const snap = getSnapshot(prevId); if (snap) { const normalized = normalizeSnapshotRecord(snap); @@ -1645,7 +1645,7 @@ async function rollbackToPreviousOfAsync(messageId) { const id = Number(messageId); if (Number.isNaN(id)) return; - // ???????? floor>=id ? L0 + // Notify L0 rollback hook for floor >= id if (typeof globalThis.LWB_StateRollbackHook === 'function') { try { await globalThis.LWB_StateRollbackHook(id); @@ -1660,7 +1660,7 @@ async function rollbackToPreviousOfAsync(messageId) { if (mode === '2.0') { try { const mod = await import('./state2/index.js'); - await mod.restoreStateV2ToFloor(prevId); // prevId<0 ??? + await mod.restoreStateV2ToFloor(prevId); // prevId < 0 handled by implementation } catch (e) { console.error('[variablesCore][2.0] restoreStateV2ToFloor failed:', e); } @@ -1682,7 +1682,7 @@ async function rebuildVariablesFromScratch() { await mod.restoreStateV2ToFloor(lastId); return; } - // 1.0 旧逻辑 + // 1.0 legacy logic setVarDict({}); const chat = getContext()?.chat || []; for (let i = 0; i < chat.length; i++) { @@ -1876,7 +1876,7 @@ async function applyVariablesForMessage(messageId) { } catch (e) { parseErrors++; if (debugOn) { - try { xbLog.error(MODULE_ID, `plot-log 解析失败:楼�?${messageId} �?${idx + 1} 预览=${preview(b)}`, e); } catch {} + try { xbLog.error(MODULE_ID, `plot-log 解析失败:楼层${messageId} 块${idx + 1} 预览=${preview(b)}`, e); } catch {} } return; } @@ -1907,7 +1907,7 @@ async function applyVariablesForMessage(messageId) { try { xbLog.warn( MODULE_ID, - `plot-log 未产生可执行指令:楼�?${messageId} 块数=${blocks.length} 解析条目=${parsedPartsTotal} 解析失败=${parseErrors} 预览=${preview(blocks[0])}` + `plot-log 未产生可执行指令:楼层${messageId} 块数=${blocks.length} 解析条目=${parsedPartsTotal} 解析失败=${parseErrors} 预览=${preview(blocks[0])}` ); } catch {} } @@ -2183,7 +2183,7 @@ async function applyVariablesForMessage(messageId) { const denied = guardDenied ? `,被规则拦截=${guardDenied}` : ''; xbLog.warn( MODULE_ID, - `plot-log 指令执行后无变化:楼�?${messageId} 指令�?${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}` + `plot-log 指令执行后无变化:楼层${messageId} 指令数${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}` ); } catch {} } @@ -2321,7 +2321,7 @@ function bindEvents() { if (getVariablesMode() !== '2.0') clearAppliedFor(id); - // ? ?? await????? apply ???????????? + // Roll back first so re-apply uses the edited message await rollbackToPreviousOfAsync(id); setTimeout(async () => { @@ -2358,7 +2358,7 @@ function bindEvents() { lastSwipedId = id; if (getVariablesMode() !== '2.0') clearAppliedFor(id); - // ? ?? await??????????????? + // Roll back first so swipe applies cleanly await rollbackToPreviousOfAsync(id); const tId = setTimeout(async () => { @@ -2377,10 +2377,10 @@ function bindEvents() { const id = getMsgIdStrict(data); if (typeof id !== 'number') return; - // ? ????????await ??????? + // Roll back first before delete handling await rollbackToPreviousOfAsync(id); - // ✅ 2.0:物理删除消息 => 同步清理 WAL/ckpt,避免膨胀 + // 2.0: physical delete -> trim WAL/ckpt to avoid bloat if (getVariablesMode() === '2.0') { try { const mod = await import('./state2/index.js'); diff --git a/package.json b/package.json index 6e58e74..a20bd28 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "lint": "eslint \"**/*.js\"", + "lint": "node scripts/check-garbled.js && eslint \"**/*.js\"", "lint:fix": "eslint \"**/*.js\" --fix" }, "devDependencies": { diff --git a/scripts/check-garbled.js b/scripts/check-garbled.js new file mode 100644 index 0000000..76bd0c2 --- /dev/null +++ b/scripts/check-garbled.js @@ -0,0 +1,80 @@ +/* eslint-env node */ +import fs from 'fs'; +import path from 'path'; + +const root = process.cwd(); +const includeExts = new Set(['.js', '.html', '.css']); +const ignoreDirs = new Set(['node_modules', '.git']); + +const patterns = [ + { name: 'question-marks', regex: /\?\?\?/g }, + { name: 'replacement-char', regex: /\uFFFD/g }, +]; + +function isIgnoredDir(dirName) { + return ignoreDirs.has(dirName); +} + +function walk(dir, files = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (isIgnoredDir(entry.name)) continue; + walk(path.join(dir, entry.name), files); + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (includeExts.has(ext)) { + files.push(path.join(dir, entry.name)); + } + } + } + return files; +} + +function scanFile(filePath) { + let content = ''; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + return []; + } + + const lines = content.split(/\r?\n/); + const hits = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const { name, regex } of patterns) { + regex.lastIndex = 0; + if (regex.test(line)) { + const preview = line.replace(/\t/g, '\\t').slice(0, 200); + hits.push({ line: i + 1, name, preview }); + } + } + } + + return hits; +} + +const files = walk(root); +const issues = []; + +for (const file of files) { + const hits = scanFile(file); + if (hits.length) { + issues.push({ file, hits }); + } +} + +if (issues.length) { + console.error('Garbled text check failed:'); + for (const issue of issues) { + const rel = path.relative(root, issue.file); + for (const hit of issue.hits) { + console.error(`- ${rel}:${hit.line} [${hit.name}] ${hit.preview}`); + } + } + process.exit(1); +} else { + console.log('Garbled text check passed.'); +}