Update story-summary modules
This commit is contained in:
378
modules/story-summary/llm-service.js
Normal file
378
modules/story-summary/llm-service.js
Normal file
@@ -0,0 +1,378 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Story Summary - LLM Service
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 常量
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const PROVIDER_MAP = {
|
||||
openai: "openai",
|
||||
google: "gemini",
|
||||
gemini: "gemini",
|
||||
claude: "claude",
|
||||
anthropic: "claude",
|
||||
deepseek: "deepseek",
|
||||
cohere: "cohere",
|
||||
custom: "custom",
|
||||
};
|
||||
|
||||
const LLM_PROMPT_CONFIG = {
|
||||
topSystem: `Story Analyst: This task involves narrative comprehension and structured incremental summarization, representing creative story analysis at the intersection of plot tracking and character development. As a story analyst, you will conduct systematic evaluation of provided dialogue content to generate structured incremental summary data.
|
||||
[Read the settings for this task]
|
||||
<task_settings>
|
||||
Incremental_Summary_Requirements:
|
||||
- Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结
|
||||
- Event_Granularity: 记录有叙事价值的事件,而非剧情梗概
|
||||
- Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册
|
||||
- Event_Classification:
|
||||
type:
|
||||
- 相遇: 人物/事物初次接触
|
||||
- 冲突: 对抗、矛盾激化
|
||||
- 揭示: 真相、秘密、身份
|
||||
- 抉择: 关键决定
|
||||
- 羁绊: 关系加深或破裂
|
||||
- 转变: 角色/局势改变
|
||||
- 收束: 问题解决、和解
|
||||
- 日常: 生活片段
|
||||
weight:
|
||||
- 核心: 删掉故事就崩
|
||||
- 主线: 推动主要剧情
|
||||
- 转折: 改变某条线走向
|
||||
- 点睛: 有细节不影响主线
|
||||
- 氛围: 纯粹氛围片段
|
||||
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
|
||||
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
|
||||
</task_settings>
|
||||
---
|
||||
Story Analyst:
|
||||
[Responsibility Definition]
|
||||
\`\`\`yaml
|
||||
analysis_task:
|
||||
title: Incremental Story Summarization
|
||||
Story Analyst:
|
||||
role: Antigravity
|
||||
task: >-
|
||||
To analyze provided dialogue content against existing summary state,
|
||||
extract only NEW plot elements, character developments, relationship
|
||||
changes, and arc progressions, outputting structured JSON for
|
||||
incremental summary database updates.
|
||||
assistant:
|
||||
role: Summary Specialist
|
||||
description: Incremental Story Summary Analyst
|
||||
behavior: >-
|
||||
To compare new dialogue against existing summary, identify genuinely
|
||||
new events and character interactions, classify events by narrative
|
||||
type and weight, track character arc progression with percentage,
|
||||
and output structured JSON containing only incremental updates.
|
||||
Must strictly avoid repeating any existing summary content.
|
||||
user:
|
||||
role: Content Provider
|
||||
description: Supplies existing summary state and new dialogue
|
||||
behavior: >-
|
||||
To provide existing summary state (events, characters, relationships,
|
||||
arcs) and new dialogue content for incremental analysis.
|
||||
interaction_mode:
|
||||
type: incremental_analysis
|
||||
output_format: structured_json
|
||||
deduplication: strict_enforcement
|
||||
execution_context:
|
||||
summary_active: true
|
||||
incremental_only: true
|
||||
memory_album_style: true
|
||||
\`\`\`
|
||||
---
|
||||
Summary Specialist:
|
||||
<Chat_History>`,
|
||||
|
||||
assistantDoc: `
|
||||
Summary Specialist:
|
||||
Acknowledged. Now reviewing the incremental summarization specifications:
|
||||
|
||||
[Event Classification System]
|
||||
├─ Types: 相遇|冲突|揭示|抉择|羁绊|转变|收束|日常
|
||||
├─ Weights: 核心|主线|转折|点睛|氛围
|
||||
└─ Each event needs: id, title, timeLabel, summary(含楼层), participants, type, weight
|
||||
|
||||
[Relationship Trend Scale]
|
||||
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
|
||||
|
||||
[Arc Progress Tracking]
|
||||
├─ trajectory: 完整弧光链描述(30字内)
|
||||
├─ progress: 0.0 to 1.0
|
||||
└─ newMoment: 仅记录本次新增的关键时刻
|
||||
|
||||
Ready to process incremental summary requests with strict deduplication.`,
|
||||
|
||||
assistantAskSummary: `
|
||||
Summary Specialist:
|
||||
Specifications internalized. Please provide the existing summary state so I can:
|
||||
1. Index all recorded events to avoid duplication
|
||||
2. Map current character relationships as baseline
|
||||
3. Note existing arc progress levels
|
||||
4. Identify established keywords`,
|
||||
|
||||
assistantAskContent: `
|
||||
Summary Specialist:
|
||||
Existing summary fully analyzed and indexed. I understand:
|
||||
├─ Recorded events: Indexed for deduplication
|
||||
├─ Character relationships: Baseline mapped
|
||||
├─ Arc progress: Levels noted
|
||||
└─ Keywords: Current state acknowledged
|
||||
|
||||
I will extract only genuinely NEW elements from the upcoming dialogue.
|
||||
Please provide the new dialogue content requiring incremental analysis.`,
|
||||
|
||||
metaProtocolStart: `
|
||||
Summary Specialist:
|
||||
ACKNOWLEDGED. Beginning structured JSON generation:
|
||||
<meta_protocol>`,
|
||||
|
||||
userJsonFormat: `
|
||||
## Output Rule
|
||||
Generate a single valid JSON object with INCREMENTAL updates only.
|
||||
|
||||
## Mindful Approach
|
||||
Before generating, observe the USER and analyze carefully:
|
||||
- What is user's writing style and emotional expression?
|
||||
- What NEW events occurred (not in existing summary)?
|
||||
- What NEW characters appeared for the first time?
|
||||
- What relationship CHANGES happened?
|
||||
- What arc PROGRESS was made?
|
||||
|
||||
## Output Format
|
||||
\`\`\`json
|
||||
{
|
||||
"mindful_prelude": {
|
||||
"user_insight": 用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
|
||||
"dedup_analysis": "已有X个事件,本次识别Y个新事件",
|
||||
},
|
||||
"keywords": [
|
||||
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"id": "evt-{nextEventId}起始,依次递增",
|
||||
"title": "地点·事件标题",
|
||||
"timeLabel": "时间线标签(如:开场、第二天晚上)",
|
||||
"summary": "1-2句话描述,涵盖丰富信息素,末尾标注楼层(#X-Y)",
|
||||
"participants": ["参与角色名"],
|
||||
"type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常",
|
||||
"weight": "核心|主线|转折|点睛|氛围"
|
||||
}
|
||||
],
|
||||
"newCharacters": ["仅本次首次出现的角色名"],
|
||||
"newRelationships": [
|
||||
{"from": "A", "to": "B", "label": "基于全局的关系描述", "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融"}
|
||||
],
|
||||
"arcUpdates": [
|
||||
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## CRITICAL NOTES
|
||||
- events.id 从 evt-{nextEventId} 开始编号
|
||||
- 仅输出【增量】内容,已有事件绝不重复
|
||||
- keywords 是全局关键词,综合已有+新增
|
||||
- 合法JSON,字符串值内部避免英文双引号
|
||||
- Output single valid JSON only
|
||||
</meta_protocol>`,
|
||||
|
||||
assistantCheck: `Content review initiated...
|
||||
[Compliance Check Results]
|
||||
├─ Existing summary loaded: ✓ Fully indexed
|
||||
├─ New dialogue received: ✓ Content parsed
|
||||
├─ Deduplication engine: ✓ Active
|
||||
├─ Event classification: ✓ Ready
|
||||
└─ Output format: ✓ JSON specification loaded
|
||||
|
||||
[Material Verification]
|
||||
├─ Existing events: Indexed ({existingEventCount} recorded)
|
||||
├─ Character baseline: Mapped
|
||||
├─ Relationship baseline: Mapped
|
||||
├─ Arc progress baseline: Noted
|
||||
└─ Output specification: ✓ Defined in <meta_protocol>
|
||||
All checks passed. Beginning incremental extraction...
|
||||
{
|
||||
"mindful_prelude":`,
|
||||
|
||||
userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容
|
||||
</Chat_History>`,
|
||||
|
||||
assistantPrefill: `非常抱歉!现在重新完整生成JSON。`
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function b64UrlEncode(str) {
|
||||
const utf8 = new TextEncoder().encode(String(str));
|
||||
let bin = '';
|
||||
utf8.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function getStreamingModule() {
|
||||
const mod = window.xiaobaixStreamingGeneration;
|
||||
return mod?.xbgenrawCommand ? mod : null;
|
||||
}
|
||||
|
||||
function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const poll = () => {
|
||||
const { isStreaming, text } = streamingMod.getStatus(sessionId);
|
||||
if (!isStreaming) return resolve(text || '');
|
||||
if (Date.now() - start > timeout) return reject(new Error('生成超时'));
|
||||
setTimeout(poll, 300);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 提示词构建
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function buildSummaryMessages(existingSummary, newHistoryText, historyRange, nextEventId, existingEventCount) {
|
||||
// 替换动态内容
|
||||
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
|
||||
.replace(/\{nextEventId\}/g, String(nextEventId));
|
||||
|
||||
const checkContent = LLM_PROMPT_CONFIG.assistantCheck
|
||||
.replace(/\{existingEventCount\}/g, String(existingEventCount));
|
||||
|
||||
// 顶部消息:系统设定 + 多轮对话引导
|
||||
const topMessages = [
|
||||
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
|
||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
|
||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
|
||||
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>` },
|
||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
|
||||
{ role: 'user', content: `<新对话内容>(${historyRange})\n${newHistoryText}\n</新对话内容>` }
|
||||
];
|
||||
|
||||
// 底部消息:元协议 + 格式要求 + 合规检查 + 催促
|
||||
const bottomMessages = [
|
||||
{ role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
|
||||
{ role: 'assistant', content: checkContent },
|
||||
{ role: 'user', content: LLM_PROMPT_CONFIG.userConfirm }
|
||||
];
|
||||
|
||||
return {
|
||||
top64: b64UrlEncode(JSON.stringify(topMessages)),
|
||||
bottom64: b64UrlEncode(JSON.stringify(bottomMessages)),
|
||||
assistantPrefill: LLM_PROMPT_CONFIG.assistantPrefill
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// JSON 解析
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function parseSummaryJson(raw) {
|
||||
if (!raw) return null;
|
||||
|
||||
let cleaned = String(raw).trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
// 直接解析
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {}
|
||||
|
||||
// 提取 JSON 对象
|
||||
const start = cleaned.indexOf('{');
|
||||
const end = cleaned.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
let jsonStr = cleaned.slice(start, end + 1)
|
||||
.replace(/,(\s*[}\]])/g, '$1'); // 移除尾部逗号
|
||||
try {
|
||||
return JSON.parse(jsonStr);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 主生成函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function generateSummary(options) {
|
||||
const {
|
||||
existingSummary,
|
||||
newHistoryText,
|
||||
historyRange,
|
||||
nextEventId,
|
||||
existingEventCount = 0,
|
||||
llmApi = {},
|
||||
genParams = {},
|
||||
useStream = true,
|
||||
timeout = 120000,
|
||||
sessionId = 'xb_summary'
|
||||
} = options;
|
||||
|
||||
if (!newHistoryText?.trim()) {
|
||||
throw new Error('新对话内容为空');
|
||||
}
|
||||
|
||||
const streamingMod = getStreamingModule();
|
||||
if (!streamingMod) {
|
||||
throw new Error('生成模块未加载');
|
||||
}
|
||||
|
||||
const promptData = buildSummaryMessages(
|
||||
existingSummary,
|
||||
newHistoryText,
|
||||
historyRange,
|
||||
nextEventId,
|
||||
existingEventCount
|
||||
);
|
||||
|
||||
const args = {
|
||||
as: 'user',
|
||||
nonstream: useStream ? 'false' : 'true',
|
||||
top64: promptData.top64,
|
||||
bottom64: promptData.bottom64,
|
||||
bottomassistant: promptData.assistantPrefill,
|
||||
id: sessionId,
|
||||
};
|
||||
|
||||
// API 配置(非酒馆主 API)
|
||||
if (llmApi.provider && llmApi.provider !== 'st') {
|
||||
const mappedApi = PROVIDER_MAP[String(llmApi.provider).toLowerCase()];
|
||||
if (mappedApi) {
|
||||
args.api = mappedApi;
|
||||
if (llmApi.url) args.apiurl = llmApi.url;
|
||||
if (llmApi.key) args.apipassword = llmApi.key;
|
||||
if (llmApi.model) args.model = llmApi.model;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成参数
|
||||
if (genParams.temperature != null) args.temperature = genParams.temperature;
|
||||
if (genParams.top_p != null) args.top_p = genParams.top_p;
|
||||
if (genParams.top_k != null) args.top_k = genParams.top_k;
|
||||
if (genParams.presence_penalty != null) args.presence_penalty = genParams.presence_penalty;
|
||||
if (genParams.frequency_penalty != null) args.frequency_penalty = genParams.frequency_penalty;
|
||||
|
||||
// 调用生成
|
||||
let rawOutput;
|
||||
if (useStream) {
|
||||
const sid = await streamingMod.xbgenrawCommand(args, '');
|
||||
rawOutput = await waitForStreamingComplete(sid, streamingMod, timeout);
|
||||
} else {
|
||||
rawOutput = await streamingMod.xbgenrawCommand(args, '');
|
||||
}
|
||||
|
||||
console.group('%c[Story-Summary] LLM输出', 'color: #7c3aed; font-weight: bold');
|
||||
console.log(rawOutput);
|
||||
console.groupEnd();
|
||||
|
||||
return rawOutput;
|
||||
}
|
||||
@@ -21,6 +21,10 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.confirm-modal-box {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.fact-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -73,6 +77,7 @@
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* ── Base ── */
|
||||
--bg: #f0f0f0;
|
||||
--bg2: #ffffff;
|
||||
--bg3: #eeeeee;
|
||||
@@ -80,22 +85,65 @@
|
||||
--txt2: #333333;
|
||||
--txt3: #555555;
|
||||
|
||||
/* Neo-Brutalism Core */
|
||||
/* ── Neo-Brutalism Core ── */
|
||||
--bdr: #000000;
|
||||
--bdr2: #000000;
|
||||
/* Secondary border is also black/high contrast */
|
||||
--shadow: 4px 4px 0 var(--txt);
|
||||
--shadow-hover: 2px 2px 0 var(--txt);
|
||||
|
||||
--acc: #000000;
|
||||
--hl: #ff4444;
|
||||
/* Harsh Red */
|
||||
--hl2: #d85858;
|
||||
--hl-soft: #ffeaea;
|
||||
/* Light Red bg */
|
||||
--inv: #fff;
|
||||
|
||||
/* ── Buttons ── */
|
||||
--btn-p-hover: #333;
|
||||
--btn-p-disabled: #999;
|
||||
|
||||
/* ── Status ── */
|
||||
--warn: #ff9800;
|
||||
--success: #22c55e;
|
||||
--info: #3b82f6;
|
||||
--downloading: #f59e0b;
|
||||
--error: #ef4444;
|
||||
|
||||
/* ── Code blocks ── */
|
||||
--code-bg: #1e1e1e;
|
||||
--code-txt: #d4d4d4;
|
||||
--muted: #999;
|
||||
|
||||
/* ── Overlay ── */
|
||||
--overlay: rgba(0, 0, 0, .5);
|
||||
|
||||
/* ── Tag ── */
|
||||
--tag-s-bdr: rgba(255, 68, 68, .2);
|
||||
--tag-shadow: rgba(0, 0, 0, .12);
|
||||
|
||||
/* ── Category colors ── */
|
||||
--cat-status: #e57373;
|
||||
--cat-inventory: #64b5f6;
|
||||
--cat-relation: #ba68c8;
|
||||
--cat-knowledge: #4db6ac;
|
||||
--cat-rule: #ffd54f;
|
||||
|
||||
/* ── Trend colors ── */
|
||||
--trend-broken: #444;
|
||||
--trend-broken-bg: rgba(68, 68, 68, .15);
|
||||
--trend-hate: #8b0000;
|
||||
--trend-hate-bg: rgba(139, 0, 0, .15);
|
||||
--trend-dislike: #cd5c5c;
|
||||
--trend-dislike-bg: rgba(205, 92, 92, .15);
|
||||
--trend-stranger: #888;
|
||||
--trend-stranger-bg: rgba(136, 136, 136, .15);
|
||||
--trend-click: #4a9a7e;
|
||||
--trend-click-bg: rgba(102, 205, 170, .15);
|
||||
--trend-close-bg: rgba(235, 106, 106, .15);
|
||||
--trend-merge: #c71585;
|
||||
--trend-merge-bg: rgba(199, 21, 133, .2);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root[data-theme="dark"] {
|
||||
/* ── Base ── */
|
||||
--bg: #111111;
|
||||
--bg2: #222222;
|
||||
--bg3: #333333;
|
||||
@@ -103,13 +151,61 @@
|
||||
--txt2: #eeeeee;
|
||||
--txt3: #cccccc;
|
||||
|
||||
/* ── Neo-Brutalism Core ── */
|
||||
--bdr: #ffffff;
|
||||
--bdr2: #ffffff;
|
||||
|
||||
--shadow: 4px 4px 0 var(--txt);
|
||||
--shadow-hover: 2px 2px 0 var(--txt);
|
||||
--acc: #ffffff;
|
||||
--hl: #ff6b6b;
|
||||
--hl2: #e07070;
|
||||
--hl-soft: #442222;
|
||||
}
|
||||
--inv: #222;
|
||||
|
||||
/* ── Buttons ── */
|
||||
--btn-p-hover: #ddd;
|
||||
--btn-p-disabled: #666;
|
||||
|
||||
/* ── Status ── */
|
||||
--warn: #ffb74d;
|
||||
--success: #4caf50;
|
||||
--info: #64b5f6;
|
||||
--downloading: #ffa726;
|
||||
--error: #ef5350;
|
||||
|
||||
/* ── Code blocks ── */
|
||||
--code-bg: #0d0d0d;
|
||||
--code-txt: #d4d4d4;
|
||||
--muted: #777;
|
||||
|
||||
/* ── Overlay ── */
|
||||
--overlay: rgba(0, 0, 0, .7);
|
||||
|
||||
/* ── Tag ── */
|
||||
--tag-s-bdr: rgba(255, 107, 107, .3);
|
||||
--tag-shadow: rgba(0, 0, 0, .4);
|
||||
|
||||
/* ── Category colors ── */
|
||||
--cat-status: #ef9a9a;
|
||||
--cat-inventory: #90caf9;
|
||||
--cat-relation: #ce93d8;
|
||||
--cat-knowledge: #80cbc4;
|
||||
--cat-rule: #ffe082;
|
||||
|
||||
/* ── Trend colors ── */
|
||||
--trend-broken: #999;
|
||||
--trend-broken-bg: rgba(153, 153, 153, .15);
|
||||
--trend-hate: #ef5350;
|
||||
--trend-hate-bg: rgba(239, 83, 80, .15);
|
||||
--trend-dislike: #e57373;
|
||||
--trend-dislike-bg: rgba(229, 115, 115, .15);
|
||||
--trend-stranger: #aaa;
|
||||
--trend-stranger-bg: rgba(170, 170, 170, .12);
|
||||
--trend-click: #66bb6a;
|
||||
--trend-click-bg: rgba(102, 187, 106, .15);
|
||||
--trend-close-bg: rgba(255, 107, 107, .15);
|
||||
--trend-merge: #f06292;
|
||||
--trend-merge-bg: rgba(240, 98, 146, .15);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -218,7 +314,7 @@ h1 {
|
||||
|
||||
.stat-warning {
|
||||
font-size: .625rem;
|
||||
color: #ff9800;
|
||||
color: var(--warn);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -705,7 +801,7 @@ h1 {
|
||||
|
||||
.prof-prog-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
||||
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||
border-radius: 2px;
|
||||
transition: width .6s;
|
||||
}
|
||||
@@ -810,38 +906,38 @@ h1 {
|
||||
}
|
||||
|
||||
.trend-broken {
|
||||
background: rgba(68, 68, 68, .15);
|
||||
color: #444;
|
||||
background: var(--trend-broken-bg);
|
||||
color: var(--trend-broken);
|
||||
}
|
||||
|
||||
.trend-hate {
|
||||
background: rgba(139, 0, 0, .15);
|
||||
color: #8b0000;
|
||||
background: var(--trend-hate-bg);
|
||||
color: var(--trend-hate);
|
||||
}
|
||||
|
||||
.trend-dislike {
|
||||
background: rgba(205, 92, 92, .15);
|
||||
color: #cd5c5c;
|
||||
background: var(--trend-dislike-bg);
|
||||
color: var(--trend-dislike);
|
||||
}
|
||||
|
||||
.trend-stranger {
|
||||
background: rgba(136, 136, 136, .15);
|
||||
color: #888;
|
||||
background: var(--trend-stranger-bg);
|
||||
color: var(--trend-stranger);
|
||||
}
|
||||
|
||||
.trend-click {
|
||||
background: rgba(102, 205, 170, .15);
|
||||
color: #4a9a7e;
|
||||
background: var(--trend-click-bg);
|
||||
color: var(--trend-click);
|
||||
}
|
||||
|
||||
.trend-close {
|
||||
background: rgba(235, 106, 106, .15);
|
||||
background: var(--trend-close-bg);
|
||||
color: var(--hl);
|
||||
}
|
||||
|
||||
.trend-merge {
|
||||
background: rgba(199, 21, 133, .2);
|
||||
color: #c71585;
|
||||
background: var(--trend-merge-bg);
|
||||
color: var(--trend-merge);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -1009,7 +1105,7 @@ h1 {
|
||||
}
|
||||
|
||||
.modal-close:hover svg {
|
||||
stroke: #fff;
|
||||
stroke: var(--inv);
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
@@ -1361,24 +1457,24 @@ h1 {
|
||||
}
|
||||
|
||||
.status-dot.ready {
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-dot.cached {
|
||||
background: #3b82f6;
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.status-dot.downloading {
|
||||
background: #f59e0b;
|
||||
background: var(--downloading);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.status-dot.success {
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -1406,7 +1502,7 @@ h1 {
|
||||
|
||||
.progress-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
||||
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||
border-radius: 3px;
|
||||
width: 0%;
|
||||
transition: width .3s;
|
||||
@@ -1445,7 +1541,7 @@ h1 {
|
||||
|
||||
.vector-mismatch-warning {
|
||||
font-size: .75rem;
|
||||
color: #f59e0b;
|
||||
color: var(--downloading);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@@ -1499,7 +1595,7 @@ h1 {
|
||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #e8e8e8;
|
||||
color: var(--code-txt);
|
||||
white-space: pre-wrap !important;
|
||||
overflow-x: hidden !important;
|
||||
word-break: break-word;
|
||||
@@ -1570,7 +1666,7 @@ h1 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--acc);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1660,7 +1756,7 @@ h1 {
|
||||
.hf-code {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
background: #1e1e1e;
|
||||
background: var(--code-bg);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
@@ -1669,7 +1765,7 @@ h1 {
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
color: var(--code-txt);
|
||||
display: block;
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -1681,7 +1777,7 @@ h1 {
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, .1);
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
color: #999;
|
||||
color: var(--muted);
|
||||
font-size: .6875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
@@ -1690,14 +1786,14 @@ h1 {
|
||||
|
||||
.hf-code .copy-btn:hover {
|
||||
background: rgba(255, 255, 255, .2);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
}
|
||||
|
||||
.hf-status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: rgba(34, 197, 94, .15);
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
border-radius: 10px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
@@ -1856,23 +1952,23 @@ h1 {
|
||||
|
||||
/* Category Icon Colors */
|
||||
.world-group[data-category="status"] .world-group-title {
|
||||
color: #e57373;
|
||||
color: var(--cat-status);
|
||||
}
|
||||
|
||||
.world-group[data-category="inventory"] .world-group-title {
|
||||
color: #64b5f6;
|
||||
color: var(--cat-inventory);
|
||||
}
|
||||
|
||||
.world-group[data-category="relation"] .world-group-title {
|
||||
color: #ba68c8;
|
||||
color: var(--cat-relation);
|
||||
}
|
||||
|
||||
.world-group[data-category="knowledge"] .world-group-title {
|
||||
color: #4db6ac;
|
||||
color: var(--cat-knowledge);
|
||||
}
|
||||
|
||||
.world-group[data-category="rule"] .world-group-title {
|
||||
color: #ffd54f;
|
||||
color: var(--cat-rule);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@@ -1971,7 +2067,7 @@ h1 {
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid #fff;
|
||||
border: solid var(--inv);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -2205,8 +2301,8 @@ h1 {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.debug-log-viewer {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-txt);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||
@@ -2221,7 +2317,7 @@ h1 {
|
||||
}
|
||||
|
||||
.recall-empty {
|
||||
color: #999;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
@@ -2234,15 +2330,15 @@ h1 {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
#recall-log-content .metric-warn {
|
||||
color: #f59e0b;
|
||||
color: var(--downloading);
|
||||
}
|
||||
|
||||
#recall-log-content .metric-error {
|
||||
color: #ef4444;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
#recall-log-content .metric-good {
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -2485,8 +2581,8 @@ h1 {
|
||||
|
||||
.neo-badge {
|
||||
/* Explicitly requested Black Background & White Text */
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--acc);
|
||||
color: var(--inv);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -358,8 +358,8 @@
|
||||
postMsg('ANCHOR_GENERATE');
|
||||
};
|
||||
|
||||
$('btn-anchor-clear').onclick = () => {
|
||||
if (confirm('清空所有记忆锚点?(L0 向量也会一并清除)')) {
|
||||
$('btn-anchor-clear').onclick = async () => {
|
||||
if (await showConfirm('清空锚点', '清空所有记忆锚点?(L0 向量也会一并清除)')) {
|
||||
postMsg('ANCHOR_CLEAR');
|
||||
}
|
||||
};
|
||||
@@ -375,6 +375,7 @@
|
||||
};
|
||||
|
||||
$('btn-test-vector-api').onclick = () => {
|
||||
saveConfig(); // 先保存新 Key 到 localStorage
|
||||
postMsg('VECTOR_TEST_ONLINE', {
|
||||
provider: 'siliconflow',
|
||||
config: {
|
||||
@@ -391,8 +392,10 @@
|
||||
postMsg('VECTOR_GENERATE', { config: getVectorConfig() });
|
||||
};
|
||||
|
||||
$('btn-clear-vectors').onclick = () => {
|
||||
if (confirm('确定清空所有向量数据?')) postMsg('VECTOR_CLEAR');
|
||||
$('btn-clear-vectors').onclick = async () => {
|
||||
if (await showConfirm('清空向量', '确定清空所有向量数据?')) {
|
||||
postMsg('VECTOR_CLEAR');
|
||||
}
|
||||
};
|
||||
|
||||
$('btn-cancel-vectors').onclick = () => postMsg('VECTOR_CANCEL_GENERATE');
|
||||
@@ -955,6 +958,43 @@
|
||||
postMsg('FULLSCREEN_CLOSED');
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示通用确认弹窗
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function showConfirm(title, message, okText = '执行', cancelText = '取消') {
|
||||
return new Promise(resolve => {
|
||||
const modal = $('confirm-modal');
|
||||
const titleEl = $('confirm-title');
|
||||
const msgEl = $('confirm-message');
|
||||
const okBtn = $('confirm-ok');
|
||||
const cancelBtn = $('confirm-cancel');
|
||||
const closeBtn = $('confirm-close');
|
||||
const backdrop = $('confirm-backdrop');
|
||||
|
||||
titleEl.textContent = title;
|
||||
msgEl.textContent = message;
|
||||
okBtn.textContent = okText;
|
||||
cancelBtn.textContent = cancelText;
|
||||
|
||||
const close = (result) => {
|
||||
modal.classList.remove('active');
|
||||
okBtn.onclick = null;
|
||||
cancelBtn.onclick = null;
|
||||
closeBtn.onclick = null;
|
||||
backdrop.onclick = null;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
okBtn.onclick = () => close(true);
|
||||
cancelBtn.onclick = () => close(false);
|
||||
closeBtn.onclick = () => close(false);
|
||||
backdrop.onclick = () => close(false);
|
||||
|
||||
modal.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
function renderArcsEditor(arcs) {
|
||||
const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }];
|
||||
const es = $('editor-struct');
|
||||
@@ -1526,7 +1566,11 @@
|
||||
};
|
||||
|
||||
// Main actions
|
||||
$('btn-clear').onclick = () => postMsg('REQUEST_CLEAR');
|
||||
$('btn-clear').onclick = async () => {
|
||||
if (await showConfirm('清空数据', '确定要清空本聊天的所有总结、关键词及人物关系数据吗?此操作不可撤销。')) {
|
||||
postMsg('REQUEST_CLEAR');
|
||||
}
|
||||
};
|
||||
$('btn-generate').onclick = () => {
|
||||
const btn = $('btn-generate');
|
||||
if (!localGenerating) {
|
||||
@@ -1640,42 +1684,34 @@
|
||||
|
||||
bindEvents();
|
||||
|
||||
// === EASTER EGG: 连续点击标题「总结」5 次切换新野兽派主题(localStorage 持久化)===
|
||||
// === THEME SWITCHER ===
|
||||
(function () {
|
||||
const STORAGE_KEY = 'xb-theme-alt';
|
||||
const CSS_A = 'story-summary.css';
|
||||
const CSS_B = 'story-summary-a.css';
|
||||
const CSS_MAP = { default: 'story-summary.css', dark: 'story-summary.css', neo: 'story-summary-a.css', 'neo-dark': 'story-summary-a.css' };
|
||||
const link = document.querySelector('link[rel="stylesheet"]');
|
||||
if (!link) return;
|
||||
const sel = document.getElementById('theme-select');
|
||||
if (!link || !sel) return;
|
||||
|
||||
// 启动时:根据持久化状态设置 CSS
|
||||
if (localStorage.getItem(STORAGE_KEY) === '1') {
|
||||
link.setAttribute('href', CSS_B);
|
||||
function applyTheme(theme) {
|
||||
if (!CSS_MAP[theme]) return;
|
||||
link.setAttribute('href', CSS_MAP[theme]);
|
||||
document.documentElement.setAttribute('data-theme', (theme === 'dark' || theme === 'neo-dark') ? 'dark' : '');
|
||||
}
|
||||
|
||||
// 点击计数器
|
||||
let clickCount = 0, clickTimer = null;
|
||||
const trigger = document.querySelector('h1 span');
|
||||
if (!trigger) return;
|
||||
// 启动时恢复主题
|
||||
const saved = localStorage.getItem(STORAGE_KEY) || 'default';
|
||||
applyTheme(saved);
|
||||
sel.value = saved;
|
||||
|
||||
trigger.style.cursor = 'default';
|
||||
trigger.addEventListener('click', function () {
|
||||
clickCount++;
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = setTimeout(() => { clickCount = 0; }, 2000);
|
||||
|
||||
if (clickCount >= 5) {
|
||||
clickCount = 0;
|
||||
clearTimeout(clickTimer);
|
||||
const isAlt = localStorage.getItem(STORAGE_KEY) === '1';
|
||||
const next = isAlt ? CSS_A : CSS_B;
|
||||
localStorage.setItem(STORAGE_KEY, isAlt ? '0' : '1');
|
||||
link.setAttribute('href', next);
|
||||
console.log(`[Easter Egg] Theme toggled → ${next}`);
|
||||
}
|
||||
// 下拉框切换
|
||||
sel.addEventListener('change', function () {
|
||||
const theme = sel.value;
|
||||
applyTheme(theme);
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
console.log(`[Theme] Switched → ${theme} (${CSS_MAP[theme]})`);
|
||||
});
|
||||
})();
|
||||
// === END EASTER EGG ===
|
||||
// === END THEME SWITCHER ===
|
||||
|
||||
// Notify parent
|
||||
postMsg('FRAME_READY');
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.confirm-modal-box {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.fact-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -80,6 +84,7 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* ── Base ── */
|
||||
--bg: #fafafa;
|
||||
--bg2: #fff;
|
||||
--bg3: #f5f5f5;
|
||||
@@ -90,7 +95,117 @@
|
||||
--bdr2: #e8e8e8;
|
||||
--acc: #1a1a1a;
|
||||
--hl: #d87a7a;
|
||||
--hl2: #d85858;
|
||||
--hl-soft: rgba(184, 90, 90, .1);
|
||||
--inv: #fff;
|
||||
/* text on accent/primary bg */
|
||||
|
||||
/* ── Buttons ── */
|
||||
--btn-p-hover: #555;
|
||||
--btn-p-disabled: #999;
|
||||
|
||||
/* ── Status ── */
|
||||
--warn: #ff9800;
|
||||
--success: #22c55e;
|
||||
--info: #3b82f6;
|
||||
--downloading: #f59e0b;
|
||||
--error: #ef4444;
|
||||
|
||||
/* ── Code blocks ── */
|
||||
--code-bg: #1e1e1e;
|
||||
--code-txt: #d4d4d4;
|
||||
--muted: #999;
|
||||
|
||||
/* ── Overlay ── */
|
||||
--overlay: rgba(0, 0, 0, .5);
|
||||
|
||||
/* ── Tag highlight border ── */
|
||||
--tag-s-bdr: rgba(255, 68, 68, .2);
|
||||
--tag-shadow: rgba(0, 0, 0, .08);
|
||||
|
||||
/* ── Category colors ── */
|
||||
--cat-status: #e57373;
|
||||
--cat-inventory: #64b5f6;
|
||||
--cat-relation: #ba68c8;
|
||||
--cat-knowledge: #4db6ac;
|
||||
--cat-rule: #ffd54f;
|
||||
|
||||
/* ── Trend colors ── */
|
||||
--trend-broken: #444;
|
||||
--trend-broken-bg: rgba(68, 68, 68, .15);
|
||||
--trend-hate: #8b0000;
|
||||
--trend-hate-bg: rgba(139, 0, 0, .15);
|
||||
--trend-dislike: #cd5c5c;
|
||||
--trend-dislike-bg: rgba(205, 92, 92, .15);
|
||||
--trend-stranger: #888;
|
||||
--trend-stranger-bg: rgba(136, 136, 136, .15);
|
||||
--trend-click: #4a9a7e;
|
||||
--trend-click-bg: rgba(102, 205, 170, .15);
|
||||
--trend-close-bg: rgba(235, 106, 106, .15);
|
||||
--trend-merge: #c71585;
|
||||
--trend-merge-bg: rgba(199, 21, 133, .2);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
/* ── Base ── */
|
||||
--bg: #121212;
|
||||
--bg2: #1e1e1e;
|
||||
--bg3: #2a2a2a;
|
||||
--txt: #e0e0e0;
|
||||
--txt2: #b0b0b0;
|
||||
--txt3: #808080;
|
||||
--bdr: #3a3a3a;
|
||||
--bdr2: #333;
|
||||
--acc: #e0e0e0;
|
||||
--hl: #e8928a;
|
||||
--hl2: #e07070;
|
||||
--hl-soft: rgba(232, 146, 138, .12);
|
||||
--inv: #1e1e1e;
|
||||
|
||||
/* ── Buttons ── */
|
||||
--btn-p-hover: #ccc;
|
||||
--btn-p-disabled: #666;
|
||||
|
||||
/* ── Status ── */
|
||||
--warn: #ffb74d;
|
||||
--success: #4caf50;
|
||||
--info: #64b5f6;
|
||||
--downloading: #ffa726;
|
||||
--error: #ef5350;
|
||||
|
||||
/* ── Code blocks ── */
|
||||
--code-bg: #0d0d0d;
|
||||
--code-txt: #d4d4d4;
|
||||
--muted: #777;
|
||||
|
||||
/* ── Overlay ── */
|
||||
--overlay: rgba(0, 0, 0, .7);
|
||||
|
||||
/* ── Tag ── */
|
||||
--tag-s-bdr: rgba(232, 146, 138, .3);
|
||||
--tag-shadow: rgba(0, 0, 0, .3);
|
||||
|
||||
/* ── Category colors (softer for dark) ── */
|
||||
--cat-status: #ef9a9a;
|
||||
--cat-inventory: #90caf9;
|
||||
--cat-relation: #ce93d8;
|
||||
--cat-knowledge: #80cbc4;
|
||||
--cat-rule: #ffe082;
|
||||
|
||||
/* ── Trend colors ── */
|
||||
--trend-broken: #999;
|
||||
--trend-broken-bg: rgba(153, 153, 153, .15);
|
||||
--trend-hate: #ef5350;
|
||||
--trend-hate-bg: rgba(239, 83, 80, .15);
|
||||
--trend-dislike: #e57373;
|
||||
--trend-dislike-bg: rgba(229, 115, 115, .15);
|
||||
--trend-stranger: #aaa;
|
||||
--trend-stranger-bg: rgba(170, 170, 170, .12);
|
||||
--trend-click: #66bb6a;
|
||||
--trend-click-bg: rgba(102, 187, 106, .15);
|
||||
--trend-close-bg: rgba(232, 146, 138, .15);
|
||||
--trend-merge: #f06292;
|
||||
--trend-merge-bg: rgba(240, 98, 146, .15);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -204,7 +319,7 @@ h1 span {
|
||||
|
||||
.stat-warning {
|
||||
font-size: .625rem;
|
||||
color: #ff9800;
|
||||
color: var(--warn);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -306,17 +421,17 @@ h1 span {
|
||||
|
||||
.btn-p {
|
||||
background: var(--acc);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-color: var(--acc);
|
||||
}
|
||||
|
||||
.btn-p:hover {
|
||||
background: #555;
|
||||
background: var(--btn-p-hover);
|
||||
}
|
||||
|
||||
.btn-p:disabled {
|
||||
background: #999;
|
||||
border-color: #999;
|
||||
background: var(--btn-p-disabled);
|
||||
border-color: var(--btn-p-disabled);
|
||||
cursor: not-allowed;
|
||||
opacity: .7;
|
||||
}
|
||||
@@ -466,20 +581,20 @@ h1 span {
|
||||
|
||||
.tag.p {
|
||||
background: var(--acc);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-color: var(--acc);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag.s {
|
||||
background: var(--hl-soft);
|
||||
border-color: rgba(255, 68, 68, .2);
|
||||
border-color: var(--tag-s-bdr);
|
||||
color: var(--hl);
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, .08);
|
||||
box-shadow: 0 4px 12px var(--tag-shadow);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -662,7 +777,7 @@ h1 span {
|
||||
|
||||
.prof-prog-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
||||
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||
border-radius: 2px;
|
||||
transition: width .6s;
|
||||
}
|
||||
@@ -769,38 +884,38 @@ h1 span {
|
||||
}
|
||||
|
||||
.trend-broken {
|
||||
background: rgba(68, 68, 68, .15);
|
||||
color: #444;
|
||||
background: var(--trend-broken-bg);
|
||||
color: var(--trend-broken);
|
||||
}
|
||||
|
||||
.trend-hate {
|
||||
background: rgba(139, 0, 0, .15);
|
||||
color: #8b0000;
|
||||
background: var(--trend-hate-bg);
|
||||
color: var(--trend-hate);
|
||||
}
|
||||
|
||||
.trend-dislike {
|
||||
background: rgba(205, 92, 92, .15);
|
||||
color: #cd5c5c;
|
||||
background: var(--trend-dislike-bg);
|
||||
color: var(--trend-dislike);
|
||||
}
|
||||
|
||||
.trend-stranger {
|
||||
background: rgba(136, 136, 136, .15);
|
||||
color: #888;
|
||||
background: var(--trend-stranger-bg);
|
||||
color: var(--trend-stranger);
|
||||
}
|
||||
|
||||
.trend-click {
|
||||
background: rgba(102, 205, 170, .15);
|
||||
color: #4a9a7e;
|
||||
background: var(--trend-click-bg);
|
||||
color: var(--trend-click);
|
||||
}
|
||||
|
||||
.trend-close {
|
||||
background: rgba(235, 106, 106, .15);
|
||||
background: var(--trend-close-bg);
|
||||
color: var(--hl);
|
||||
}
|
||||
|
||||
.trend-merge {
|
||||
background: rgba(199, 21, 133, .2);
|
||||
color: #c71585;
|
||||
background: var(--trend-merge-bg);
|
||||
color: var(--trend-merge);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -913,7 +1028,7 @@ h1 span {
|
||||
.modal-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -964,6 +1079,7 @@ h1 span {
|
||||
.modal-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--txt);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -1031,7 +1147,7 @@ h1 span {
|
||||
.editor-err {
|
||||
padding: 12px;
|
||||
background: var(--hl-soft);
|
||||
border: 1px solid rgba(255, 68, 68, .3);
|
||||
border: 1px solid var(--tag-s-bdr);
|
||||
color: var(--hl);
|
||||
font-size: .8125rem;
|
||||
margin-top: 12px;
|
||||
@@ -1301,24 +1417,24 @@ h1 span {
|
||||
}
|
||||
|
||||
.status-dot.ready {
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-dot.cached {
|
||||
background: #3b82f6;
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.status-dot.downloading {
|
||||
background: #f59e0b;
|
||||
background: var(--downloading);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.status-dot.success {
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -1346,7 +1462,7 @@ h1 span {
|
||||
|
||||
.progress-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
||||
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||
border-radius: 3px;
|
||||
width: 0%;
|
||||
transition: width .3s;
|
||||
@@ -1404,7 +1520,7 @@ h1 span {
|
||||
|
||||
.vector-mismatch-warning {
|
||||
font-size: .75rem;
|
||||
color: #f59e0b;
|
||||
color: var(--downloading);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@@ -1458,7 +1574,7 @@ h1 span {
|
||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #e8e8e8;
|
||||
color: var(--code-txt);
|
||||
white-space: pre-wrap !important;
|
||||
overflow-x: hidden !important;
|
||||
word-break: break-word;
|
||||
@@ -1468,7 +1584,7 @@ h1 span {
|
||||
}
|
||||
|
||||
.recall-empty {
|
||||
color: #999;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
@@ -1555,7 +1671,7 @@ h1 span {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--acc);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1648,7 +1764,7 @@ h1 span {
|
||||
.hf-code {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
background: #1e1e1e;
|
||||
background: var(--code-bg);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
@@ -1657,7 +1773,7 @@ h1 span {
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
color: var(--code-txt);
|
||||
display: block;
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -1669,7 +1785,7 @@ h1 span {
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, .1);
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
color: #999;
|
||||
color: var(--muted);
|
||||
font-size: .6875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
@@ -1678,14 +1794,14 @@ h1 span {
|
||||
|
||||
.hf-code .copy-btn:hover {
|
||||
background: rgba(255, 255, 255, .2);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
}
|
||||
|
||||
.hf-status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: rgba(34, 197, 94, .15);
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
border-radius: 10px;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
@@ -2291,23 +2407,23 @@ h1 span {
|
||||
|
||||
/* 分类图标颜色 */
|
||||
.world-group[data-category="status"] .world-group-title {
|
||||
color: #e57373;
|
||||
color: var(--cat-status);
|
||||
}
|
||||
|
||||
.world-group[data-category="inventory"] .world-group-title {
|
||||
color: #64b5f6;
|
||||
color: var(--cat-inventory);
|
||||
}
|
||||
|
||||
.world-group[data-category="relation"] .world-group-title {
|
||||
color: #ba68c8;
|
||||
color: var(--cat-relation);
|
||||
}
|
||||
|
||||
.world-group[data-category="knowledge"] .world-group-title {
|
||||
color: #4db6ac;
|
||||
color: var(--cat-knowledge);
|
||||
}
|
||||
|
||||
.world-group[data-category="rule"] .world-group-title {
|
||||
color: #ffd54f;
|
||||
color: var(--cat-rule);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
@@ -2444,7 +2560,7 @@ h1 span {
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid #fff;
|
||||
border: solid var(--inv);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -2740,8 +2856,8 @@ h1 span {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.debug-log-viewer {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-txt);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||
@@ -2756,7 +2872,7 @@ h1 span {
|
||||
}
|
||||
|
||||
.recall-empty {
|
||||
color: #999;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
@@ -2775,15 +2891,15 @@ h1 span {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
#recall-log-content .metric-warn {
|
||||
color: #f59e0b;
|
||||
color: var(--downloading);
|
||||
}
|
||||
|
||||
#recall-log-content .metric-error {
|
||||
color: #ef4444;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
#recall-log-content .metric-good {
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -2825,7 +2941,7 @@ h1 span {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--acc);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2872,7 +2988,7 @@ h1 span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: var(--hl);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3103,7 +3219,7 @@ h1 span {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--acc);
|
||||
color: #fff;
|
||||
color: var(--inv);
|
||||
border-radius: 3px;
|
||||
font-size: .625rem;
|
||||
font-weight: 700;
|
||||
@@ -3304,8 +3420,8 @@ h1 span {
|
||||
|
||||
.neo-badge {
|
||||
/* Explicitly requested Black Background & White Text */
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--acc);
|
||||
color: var(--inv);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -176,6 +176,22 @@
|
||||
<div class="modal-body">
|
||||
<!-- Tab 1: Summary Settings -->
|
||||
<div class="tab-pane active" id="tab-summary">
|
||||
<!-- Theme Settings -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">主题设置</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field full">
|
||||
<label>界面主题</label>
|
||||
<select id="theme-select">
|
||||
<option value="default">默认主题 · 亮色</option>
|
||||
<option value="dark">默认主题 · 暗色</option>
|
||||
<option value="neo">Neo主题 · 亮色</option>
|
||||
<option value="neo-dark">Neo主题 · 暗色</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Config & Gen Params Combined -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">API 配置</div>
|
||||
@@ -817,6 +833,28 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<script src="story-summary-ui.js"></script>
|
||||
<!-- Confirm Modal -->
|
||||
<div class="modal" id="confirm-modal">
|
||||
<div class="modal-bg" id="confirm-backdrop"></div>
|
||||
<div class="modal-box confirm-modal-box">
|
||||
<div class="modal-head">
|
||||
<h2 id="confirm-title">确认操作</h2>
|
||||
<button class="modal-close" id="confirm-close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="confirm-message" style="margin: 10px 0; line-height: 1.6; color: var(--fg);">内容</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" id="confirm-cancel">取消</button>
|
||||
<button class="btn btn-del" id="confirm-ok">执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -449,6 +449,34 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
await clearStateVectors(chatId);
|
||||
await updateMeta(chatId, { lastChunkFloor: -1, fingerprint });
|
||||
|
||||
// Helper to embed with retry
|
||||
const embedWithRetry = async (texts, phase, currentBatchIdx, totalItems) => {
|
||||
while (true) {
|
||||
if (vectorCancelled) return null;
|
||||
try {
|
||||
return await embed(texts, vectorCfg, { signal: vectorAbortController.signal });
|
||||
} catch (e) {
|
||||
if (e?.name === "AbortError" || vectorCancelled) return null;
|
||||
xbLog.error(MODULE_ID, `${phase} 向量化单次失败`, e);
|
||||
|
||||
// 等待 60 秒重试
|
||||
const waitSec = 60;
|
||||
for (let s = waitSec; s > 0; s--) {
|
||||
if (vectorCancelled) return null;
|
||||
postToFrame({
|
||||
type: "VECTOR_GEN_PROGRESS",
|
||||
phase,
|
||||
current: currentBatchIdx,
|
||||
total: totalItems,
|
||||
message: `触发限流,${s}s 后重试...`
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase, current: currentBatchIdx, total: totalItems, message: "正在重试..." });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const atoms = getStateAtoms();
|
||||
if (!atoms.length) {
|
||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L0", current: 0, total: 0, message: "L0 为空,跳过" });
|
||||
@@ -462,11 +490,14 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
const batch = atoms.slice(i, i + batchSize);
|
||||
const semTexts = batch.map(a => a.semantic);
|
||||
const rTexts = batch.map(a => buildRAggregateText(a));
|
||||
try {
|
||||
const vectors = await embed(semTexts.concat(rTexts), vectorCfg, { signal: vectorAbortController.signal });
|
||||
|
||||
const vectors = await embedWithRetry(semTexts.concat(rTexts), "L0", l0Completed, atoms.length);
|
||||
if (!vectors) break; // cancelled
|
||||
|
||||
const split = semTexts.length;
|
||||
if (!Array.isArray(vectors) || vectors.length < split * 2) {
|
||||
throw new Error(`embed length mismatch: expect>=${split * 2}, got=${vectors?.length || 0}`);
|
||||
xbLog.error(MODULE_ID, `embed长度不匹配: expect>=${split * 2}, got=${vectors?.length || 0}`);
|
||||
continue;
|
||||
}
|
||||
const semVectors = vectors.slice(0, split);
|
||||
const rVectors = vectors.slice(split, split + split);
|
||||
@@ -479,12 +510,6 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
await saveStateVectors(chatId, items, fingerprint);
|
||||
l0Completed += batch.length;
|
||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L0", current: l0Completed, total: atoms.length });
|
||||
} catch (e) {
|
||||
if (e?.name === "AbortError") break;
|
||||
xbLog.error(MODULE_ID, "L0 向量化失败", e);
|
||||
vectorCancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,8 +541,10 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
|
||||
const batch = allChunks.slice(i, i + batchSize);
|
||||
const texts = batch.map(c => c.text);
|
||||
try {
|
||||
const vectors = await embed(texts, vectorCfg, { signal: vectorAbortController.signal });
|
||||
|
||||
const vectors = await embedWithRetry(texts, "L1", l1Completed, allChunks.length);
|
||||
if (!vectors) break; // cancelled
|
||||
|
||||
const items = batch.map((c, j) => ({
|
||||
chunkId: c.chunkId,
|
||||
vector: vectors[j],
|
||||
@@ -526,12 +553,6 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
l1Vectors = l1Vectors.concat(items);
|
||||
l1Completed += batch.length;
|
||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: l1Completed, total: allChunks.length });
|
||||
} catch (e) {
|
||||
if (e?.name === "AbortError") break;
|
||||
xbLog.error(MODULE_ID, "L1 向量化失败", e);
|
||||
vectorCancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,8 +576,10 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
|
||||
const batch = l2Pairs.slice(i, i + batchSize);
|
||||
const texts = batch.map(p => p.text);
|
||||
try {
|
||||
const vectors = await embed(texts, vectorCfg, { signal: vectorAbortController.signal });
|
||||
|
||||
const vectors = await embedWithRetry(texts, "L2", l2Completed, l2Pairs.length);
|
||||
if (!vectors) break; // cancelled
|
||||
|
||||
const items = batch.map((p, idx) => ({
|
||||
eventId: p.id,
|
||||
vector: vectors[idx],
|
||||
@@ -564,12 +587,6 @@ async function handleGenerateVectors(vectorCfg) {
|
||||
await saveEventVectorsToDb(chatId, items, fingerprint);
|
||||
l2Completed += batch.length;
|
||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: l2Completed, total: l2Pairs.length });
|
||||
} catch (e) {
|
||||
if (e?.name === "AbortError") break;
|
||||
xbLog.error(MODULE_ID, "L2 向量化失败", e);
|
||||
vectorCancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { xbLog } from '../../../../core/debug-core.js';
|
||||
import { getVectorConfig } from '../../data/config.js';
|
||||
import { getApiKey } from './siliconflow.js';
|
||||
|
||||
const MODULE_ID = 'vector-llm-service';
|
||||
const SILICONFLOW_API_URL = 'https://api.siliconflow.cn/v1';
|
||||
@@ -40,8 +41,7 @@ export async function callLLM(messages, options = {}) {
|
||||
const mod = getStreamingModule();
|
||||
if (!mod) throw new Error('Streaming module not ready');
|
||||
|
||||
const cfg = getVectorConfig();
|
||||
const apiKey = cfg?.online?.key || '';
|
||||
const apiKey = getApiKey() || '';
|
||||
if (!apiKey) {
|
||||
throw new Error('L0 requires siliconflow API key');
|
||||
}
|
||||
|
||||
@@ -1,21 +1,63 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// siliconflow.js - 仅保留 Embedding
|
||||
// siliconflow.js - Embedding + 多 Key 轮询
|
||||
//
|
||||
// 在 API Key 输入框中用逗号、分号、竖线或换行分隔多个 Key,例如:
|
||||
// sk-aaa,sk-bbb,sk-ccc
|
||||
// 每次调用自动轮询到下一个 Key,并发请求会均匀分布到所有 Key 上。
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const BASE_URL = 'https://api.siliconflow.cn';
|
||||
const EMBEDDING_MODEL = 'BAAI/bge-m3';
|
||||
|
||||
export function getApiKey() {
|
||||
// ★ 多 Key 轮询状态
|
||||
let _keyIndex = 0;
|
||||
|
||||
/**
|
||||
* 从 localStorage 解析所有 Key(支持逗号、分号、竖线、换行分隔)
|
||||
*/
|
||||
function parseKeys() {
|
||||
try {
|
||||
const raw = localStorage.getItem('summary_panel_config');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed.vector?.online?.key || null;
|
||||
const keyStr = parsed.vector?.online?.key || '';
|
||||
return keyStr
|
||||
.split(/[,;|\n]+/)
|
||||
.map(k => k.trim())
|
||||
.filter(k => k.length > 0);
|
||||
}
|
||||
} catch { }
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一个可用的 API Key(轮询)
|
||||
* 每次调用返回不同的 Key,自动循环
|
||||
*/
|
||||
export function getApiKey() {
|
||||
const keys = parseKeys();
|
||||
if (!keys.length) return null;
|
||||
if (keys.length === 1) return keys[0];
|
||||
|
||||
const idx = _keyIndex % keys.length;
|
||||
const key = keys[idx];
|
||||
_keyIndex = (_keyIndex + 1) % keys.length;
|
||||
const masked = key.length > 10 ? key.slice(0, 6) + '***' + key.slice(-4) : '***';
|
||||
console.log(`[SiliconFlow] 使用 Key ${idx + 1}/${keys.length}: ${masked}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置的 Key 数量(供外部模块动态调整并发用)
|
||||
*/
|
||||
export function getKeyCount() {
|
||||
return Math.max(1, parseKeys().length);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Embedding
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function embed(texts, options = {}) {
|
||||
if (!texts?.length) return [];
|
||||
|
||||
|
||||
@@ -181,25 +181,16 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
||||
// ★ Phase 1: 收集所有新提取的 atoms(不向量化)
|
||||
const allNewAtoms = [];
|
||||
|
||||
// ★ 30 并发批次处理
|
||||
// 并发池处理(保持固定并发度)
|
||||
const poolSize = Math.min(CONCURRENCY, pendingPairs.length);
|
||||
let nextIndex = 0;
|
||||
let started = 0;
|
||||
const runWorker = async (workerId) => {
|
||||
while (true) {
|
||||
if (extractionCancelled) return;
|
||||
const idx = nextIndex++;
|
||||
if (idx >= pendingPairs.length) return;
|
||||
|
||||
const pair = pendingPairs[idx];
|
||||
const stagger = started++;
|
||||
if (STAGGER_DELAY > 0) {
|
||||
await new Promise(r => setTimeout(r, stagger * STAGGER_DELAY));
|
||||
}
|
||||
|
||||
if (extractionCancelled) return;
|
||||
// ★ 限流检测:连续失败 N 次后暂停并降速
|
||||
let consecutiveFailures = 0;
|
||||
let rateLimited = false;
|
||||
const RATE_LIMIT_THRESHOLD = 3; // 连续失败多少次触发限流保护
|
||||
const RATE_LIMIT_WAIT_MS = 60000; // 限流后等待时间(60 秒)
|
||||
const RETRY_INTERVAL_MS = 1000; // 降速模式下每次请求间隔(1 秒)
|
||||
const RETRY_CONCURRENCY = 1; // ★ 降速模式下的并发数(默认1,建议不要超过5)
|
||||
|
||||
// ★ 通用处理单个 pair 的逻辑(复用于正常模式和降速模式)
|
||||
const processPair = async (pair, idx, workerId) => {
|
||||
const floor = pair.aiFloor;
|
||||
const prev = getL0FloorStatus(floor);
|
||||
|
||||
@@ -218,12 +209,14 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
||||
throw new Error('llm_failed');
|
||||
}
|
||||
|
||||
// ★ 成功:重置连续失败计数
|
||||
consecutiveFailures = 0;
|
||||
|
||||
if (!atoms.length) {
|
||||
setL0FloorStatus(floor, { status: 'empty', reason: 'llm_empty', atoms: 0 });
|
||||
} else {
|
||||
atoms.forEach(a => a.chatId = chatId);
|
||||
saveStateAtoms(atoms);
|
||||
// Phase 1: 只收集,不向量化
|
||||
allNewAtoms.push(...atoms);
|
||||
|
||||
setL0FloorStatus(floor, { status: 'ok', atoms: atoms.length });
|
||||
@@ -238,6 +231,13 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
||||
reason: String(e?.message || e).replace(/\s+/g, ' ').slice(0, 120),
|
||||
});
|
||||
failed++;
|
||||
|
||||
// ★ 限流检测:连续失败累加
|
||||
consecutiveFailures++;
|
||||
if (consecutiveFailures >= RATE_LIMIT_THRESHOLD && !rateLimited) {
|
||||
rateLimited = true;
|
||||
xbLog.warn(MODULE_ID, `连续失败 ${consecutiveFailures} 次,疑似触发 API 限流,将暂停所有并发`);
|
||||
}
|
||||
} finally {
|
||||
active--;
|
||||
if (!extractionCancelled) {
|
||||
@@ -249,6 +249,27 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
||||
xbLog.info(MODULE_ID, `L0 pool progress=${completed}/${total} active=${active} peak=${peakActive} elapsedMs=${elapsed}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ★ 并发池处理(保持固定并发度)
|
||||
const poolSize = Math.min(CONCURRENCY, pendingPairs.length);
|
||||
let nextIndex = 0;
|
||||
let started = 0;
|
||||
const runWorker = async (workerId) => {
|
||||
while (true) {
|
||||
if (extractionCancelled || rateLimited) return;
|
||||
const idx = nextIndex++;
|
||||
if (idx >= pendingPairs.length) return;
|
||||
|
||||
const pair = pendingPairs[idx];
|
||||
const stagger = started++;
|
||||
if (STAGGER_DELAY > 0) {
|
||||
await new Promise(r => setTimeout(r, stagger * STAGGER_DELAY));
|
||||
}
|
||||
|
||||
if (extractionCancelled || rateLimited) return;
|
||||
|
||||
await processPair(pair, idx, workerId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,6 +279,61 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
||||
xbLog.info(MODULE_ID, `L0 pool done completed=${completed}/${total} failed=${failed} peakActive=${peakActive} elapsedMs=${elapsed}`);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// ★ 限流恢复:重置进度,从头开始以限速模式慢慢跑
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
if (rateLimited && !extractionCancelled) {
|
||||
const waitSec = RATE_LIMIT_WAIT_MS / 1000;
|
||||
xbLog.info(MODULE_ID, `限流保护:将重置进度并从头开始降速重来(并发=${RETRY_CONCURRENCY}, 间隔=${RETRY_INTERVAL_MS}ms)`);
|
||||
onProgress?.(`疑似限流,${waitSec}s 后降速重头开始...`, completed, total);
|
||||
|
||||
await new Promise(r => setTimeout(r, RATE_LIMIT_WAIT_MS));
|
||||
|
||||
if (!extractionCancelled) {
|
||||
// ★ 核心逻辑:重置计数器,让 UI 从 0 开始跑,给用户“重头开始”的反馈
|
||||
rateLimited = false;
|
||||
consecutiveFailures = 0;
|
||||
completed = 0;
|
||||
failed = 0;
|
||||
|
||||
let retryNextIdx = 0;
|
||||
|
||||
xbLog.info(MODULE_ID, `限流恢复:开始降速模式扫描 ${pendingPairs.length} 个楼层`);
|
||||
|
||||
const retryWorkers = Math.min(RETRY_CONCURRENCY, pendingPairs.length);
|
||||
const runRetryWorker = async (wid) => {
|
||||
while (true) {
|
||||
if (extractionCancelled) return;
|
||||
const idx = retryNextIdx++;
|
||||
if (idx >= pendingPairs.length) return;
|
||||
|
||||
const pair = pendingPairs[idx];
|
||||
const floor = pair.aiFloor;
|
||||
|
||||
// ★ 检查该楼层状态
|
||||
const st = getL0FloorStatus(floor);
|
||||
if (st?.status === 'ok' || st?.status === 'empty') {
|
||||
// 刚才已经成功了,直接跳过(仅增加进度计数)
|
||||
completed++;
|
||||
onProgress?.(`提取: ${completed}/${total} (跳过已完成)`, completed, total);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 没做过的,用 slow 模式处理
|
||||
await processPair(pair, idx, `retry-${wid}`);
|
||||
|
||||
// 每个请求后休息,避免再次触发限流
|
||||
if (idx < pendingPairs.length - 1 && RETRY_INTERVAL_MS > 0) {
|
||||
await new Promise(r => setTimeout(r, RETRY_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: retryWorkers }, (_, i) => runRetryWorker(i)));
|
||||
xbLog.info(MODULE_ID, `降速重头开始阶段结束`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
saveMetadataDebounced?.();
|
||||
} catch { }
|
||||
|
||||
Reference in New Issue
Block a user