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;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-modal-box {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
.fact-group {
|
.fact-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
═══════════════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* ── Base ── */
|
||||||
--bg: #f0f0f0;
|
--bg: #f0f0f0;
|
||||||
--bg2: #ffffff;
|
--bg2: #ffffff;
|
||||||
--bg3: #eeeeee;
|
--bg3: #eeeeee;
|
||||||
@@ -80,36 +85,127 @@
|
|||||||
--txt2: #333333;
|
--txt2: #333333;
|
||||||
--txt3: #555555;
|
--txt3: #555555;
|
||||||
|
|
||||||
/* Neo-Brutalism Core */
|
/* ── Neo-Brutalism Core ── */
|
||||||
--bdr: #000000;
|
--bdr: #000000;
|
||||||
--bdr2: #000000;
|
--bdr2: #000000;
|
||||||
/* Secondary border is also black/high contrast */
|
|
||||||
--shadow: 4px 4px 0 var(--txt);
|
--shadow: 4px 4px 0 var(--txt);
|
||||||
--shadow-hover: 2px 2px 0 var(--txt);
|
--shadow-hover: 2px 2px 0 var(--txt);
|
||||||
|
|
||||||
--acc: #000000;
|
--acc: #000000;
|
||||||
--hl: #ff4444;
|
--hl: #ff4444;
|
||||||
/* Harsh Red */
|
--hl2: #d85858;
|
||||||
--hl-soft: #ffeaea;
|
--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[data-theme="dark"] {
|
||||||
:root {
|
/* ── Base ── */
|
||||||
--bg: #111111;
|
--bg: #111111;
|
||||||
--bg2: #222222;
|
--bg2: #222222;
|
||||||
--bg3: #333333;
|
--bg3: #333333;
|
||||||
--txt: #ffffff;
|
--txt: #ffffff;
|
||||||
--txt2: #eeeeee;
|
--txt2: #eeeeee;
|
||||||
--txt3: #cccccc;
|
--txt3: #cccccc;
|
||||||
|
|
||||||
--bdr: #ffffff;
|
/* ── Neo-Brutalism Core ── */
|
||||||
--bdr2: #ffffff;
|
--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;
|
||||||
|
|
||||||
--acc: #ffffff;
|
/* ── Buttons ── */
|
||||||
--hl: #ff6b6b;
|
--btn-p-hover: #ddd;
|
||||||
--hl-soft: #442222;
|
--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 {
|
body {
|
||||||
@@ -218,7 +314,7 @@ h1 {
|
|||||||
|
|
||||||
.stat-warning {
|
.stat-warning {
|
||||||
font-size: .625rem;
|
font-size: .625rem;
|
||||||
color: #ff9800;
|
color: var(--warn);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +801,7 @@ h1 {
|
|||||||
|
|
||||||
.prof-prog-inner {
|
.prof-prog-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width .6s;
|
transition: width .6s;
|
||||||
}
|
}
|
||||||
@@ -810,38 +906,38 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-broken {
|
.trend-broken {
|
||||||
background: rgba(68, 68, 68, .15);
|
background: var(--trend-broken-bg);
|
||||||
color: #444;
|
color: var(--trend-broken);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-hate {
|
.trend-hate {
|
||||||
background: rgba(139, 0, 0, .15);
|
background: var(--trend-hate-bg);
|
||||||
color: #8b0000;
|
color: var(--trend-hate);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-dislike {
|
.trend-dislike {
|
||||||
background: rgba(205, 92, 92, .15);
|
background: var(--trend-dislike-bg);
|
||||||
color: #cd5c5c;
|
color: var(--trend-dislike);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-stranger {
|
.trend-stranger {
|
||||||
background: rgba(136, 136, 136, .15);
|
background: var(--trend-stranger-bg);
|
||||||
color: #888;
|
color: var(--trend-stranger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-click {
|
.trend-click {
|
||||||
background: rgba(102, 205, 170, .15);
|
background: var(--trend-click-bg);
|
||||||
color: #4a9a7e;
|
color: var(--trend-click);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-close {
|
.trend-close {
|
||||||
background: rgba(235, 106, 106, .15);
|
background: var(--trend-close-bg);
|
||||||
color: var(--hl);
|
color: var(--hl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-merge {
|
.trend-merge {
|
||||||
background: rgba(199, 21, 133, .2);
|
background: var(--trend-merge-bg);
|
||||||
color: #c71585;
|
color: var(--trend-merge);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -1009,7 +1105,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:hover svg {
|
.modal-close:hover svg {
|
||||||
stroke: #fff;
|
stroke: var(--inv);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close svg {
|
.modal-close svg {
|
||||||
@@ -1361,24 +1457,24 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.ready {
|
.status-dot.ready {
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.cached {
|
.status-dot.cached {
|
||||||
background: #3b82f6;
|
background: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.downloading {
|
.status-dot.downloading {
|
||||||
background: #f59e0b;
|
background: var(--downloading);
|
||||||
animation: pulse 1s infinite;
|
animation: pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.error {
|
.status-dot.error {
|
||||||
background: #ef4444;
|
background: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.success {
|
.status-dot.success {
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -1406,7 +1502,7 @@ h1 {
|
|||||||
|
|
||||||
.progress-inner {
|
.progress-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
transition: width .3s;
|
transition: width .3s;
|
||||||
@@ -1445,7 +1541,7 @@ h1 {
|
|||||||
|
|
||||||
.vector-mismatch-warning {
|
.vector-mismatch-warning {
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
color: #f59e0b;
|
color: var(--downloading);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,7 +1595,7 @@ h1 {
|
|||||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e8e8e8;
|
color: var(--code-txt);
|
||||||
white-space: pre-wrap !important;
|
white-space: pre-wrap !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -1570,7 +1666,7 @@ h1 {
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: var(--acc);
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1660,7 +1756,7 @@ h1 {
|
|||||||
.hf-code {
|
.hf-code {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
background: #1e1e1e;
|
background: var(--code-bg);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -1669,7 +1765,7 @@ h1 {
|
|||||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #d4d4d4;
|
color: var(--code-txt);
|
||||||
display: block;
|
display: block;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@@ -1681,7 +1777,7 @@ h1 {
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: rgba(255, 255, 255, .1);
|
background: rgba(255, 255, 255, .1);
|
||||||
border: 1px solid rgba(255, 255, 255, .2);
|
border: 1px solid rgba(255, 255, 255, .2);
|
||||||
color: #999;
|
color: var(--muted);
|
||||||
font-size: .6875rem;
|
font-size: .6875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -1690,14 +1786,14 @@ h1 {
|
|||||||
|
|
||||||
.hf-code .copy-btn:hover {
|
.hf-code .copy-btn:hover {
|
||||||
background: rgba(255, 255, 255, .2);
|
background: rgba(255, 255, 255, .2);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-status-badge {
|
.hf-status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
background: rgba(34, 197, 94, .15);
|
background: rgba(34, 197, 94, .15);
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
}
|
}
|
||||||
@@ -1856,23 +1952,23 @@ h1 {
|
|||||||
|
|
||||||
/* Category Icon Colors */
|
/* Category Icon Colors */
|
||||||
.world-group[data-category="status"] .world-group-title {
|
.world-group[data-category="status"] .world-group-title {
|
||||||
color: #e57373;
|
color: var(--cat-status);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="inventory"] .world-group-title {
|
.world-group[data-category="inventory"] .world-group-title {
|
||||||
color: #64b5f6;
|
color: var(--cat-inventory);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="relation"] .world-group-title {
|
.world-group[data-category="relation"] .world-group-title {
|
||||||
color: #ba68c8;
|
color: var(--cat-relation);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="knowledge"] .world-group-title {
|
.world-group[data-category="knowledge"] .world-group-title {
|
||||||
color: #4db6ac;
|
color: var(--cat-knowledge);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="rule"] .world-group-title {
|
.world-group[data-category="rule"] .world-group-title {
|
||||||
color: #ffd54f;
|
color: var(--cat-rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -1971,7 +2067,7 @@ h1 {
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border: solid #fff;
|
border: solid var(--inv);
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -2205,8 +2301,8 @@ h1 {
|
|||||||
═══════════════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.debug-log-viewer {
|
.debug-log-viewer {
|
||||||
background: #1a1a1a;
|
background: var(--code-bg);
|
||||||
color: #e0e0e0;
|
color: var(--code-txt);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||||
@@ -2221,7 +2317,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recall-empty {
|
.recall-empty {
|
||||||
color: #999;
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -2234,15 +2330,15 @@ h1 {
|
|||||||
═══════════════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
#recall-log-content .metric-warn {
|
#recall-log-content .metric-warn {
|
||||||
color: #f59e0b;
|
color: var(--downloading);
|
||||||
}
|
}
|
||||||
|
|
||||||
#recall-log-content .metric-error {
|
#recall-log-content .metric-error {
|
||||||
color: #ef4444;
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
#recall-log-content .metric-good {
|
#recall-log-content .metric-good {
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -2485,8 +2581,8 @@ h1 {
|
|||||||
|
|
||||||
.neo-badge {
|
.neo-badge {
|
||||||
/* Explicitly requested Black Background & White Text */
|
/* Explicitly requested Black Background & White Text */
|
||||||
background: #000;
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -358,8 +358,8 @@
|
|||||||
postMsg('ANCHOR_GENERATE');
|
postMsg('ANCHOR_GENERATE');
|
||||||
};
|
};
|
||||||
|
|
||||||
$('btn-anchor-clear').onclick = () => {
|
$('btn-anchor-clear').onclick = async () => {
|
||||||
if (confirm('清空所有记忆锚点?(L0 向量也会一并清除)')) {
|
if (await showConfirm('清空锚点', '清空所有记忆锚点?(L0 向量也会一并清除)')) {
|
||||||
postMsg('ANCHOR_CLEAR');
|
postMsg('ANCHOR_CLEAR');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -375,6 +375,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
$('btn-test-vector-api').onclick = () => {
|
$('btn-test-vector-api').onclick = () => {
|
||||||
|
saveConfig(); // 先保存新 Key 到 localStorage
|
||||||
postMsg('VECTOR_TEST_ONLINE', {
|
postMsg('VECTOR_TEST_ONLINE', {
|
||||||
provider: 'siliconflow',
|
provider: 'siliconflow',
|
||||||
config: {
|
config: {
|
||||||
@@ -391,8 +392,10 @@
|
|||||||
postMsg('VECTOR_GENERATE', { config: getVectorConfig() });
|
postMsg('VECTOR_GENERATE', { config: getVectorConfig() });
|
||||||
};
|
};
|
||||||
|
|
||||||
$('btn-clear-vectors').onclick = () => {
|
$('btn-clear-vectors').onclick = async () => {
|
||||||
if (confirm('确定清空所有向量数据?')) postMsg('VECTOR_CLEAR');
|
if (await showConfirm('清空向量', '确定清空所有向量数据?')) {
|
||||||
|
postMsg('VECTOR_CLEAR');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$('btn-cancel-vectors').onclick = () => postMsg('VECTOR_CANCEL_GENERATE');
|
$('btn-cancel-vectors').onclick = () => postMsg('VECTOR_CANCEL_GENERATE');
|
||||||
@@ -955,6 +958,43 @@
|
|||||||
postMsg('FULLSCREEN_CLOSED');
|
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) {
|
function renderArcsEditor(arcs) {
|
||||||
const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }];
|
const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }];
|
||||||
const es = $('editor-struct');
|
const es = $('editor-struct');
|
||||||
@@ -1526,7 +1566,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Main actions
|
// Main actions
|
||||||
$('btn-clear').onclick = () => postMsg('REQUEST_CLEAR');
|
$('btn-clear').onclick = async () => {
|
||||||
|
if (await showConfirm('清空数据', '确定要清空本聊天的所有总结、关键词及人物关系数据吗?此操作不可撤销。')) {
|
||||||
|
postMsg('REQUEST_CLEAR');
|
||||||
|
}
|
||||||
|
};
|
||||||
$('btn-generate').onclick = () => {
|
$('btn-generate').onclick = () => {
|
||||||
const btn = $('btn-generate');
|
const btn = $('btn-generate');
|
||||||
if (!localGenerating) {
|
if (!localGenerating) {
|
||||||
@@ -1640,42 +1684,34 @@
|
|||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
|
||||||
// === EASTER EGG: 连续点击标题「总结」5 次切换新野兽派主题(localStorage 持久化)===
|
// === THEME SWITCHER ===
|
||||||
(function () {
|
(function () {
|
||||||
const STORAGE_KEY = 'xb-theme-alt';
|
const STORAGE_KEY = 'xb-theme-alt';
|
||||||
const CSS_A = 'story-summary.css';
|
const CSS_MAP = { default: 'story-summary.css', dark: 'story-summary.css', neo: 'story-summary-a.css', 'neo-dark': 'story-summary-a.css' };
|
||||||
const CSS_B = 'story-summary-a.css';
|
|
||||||
const link = document.querySelector('link[rel="stylesheet"]');
|
const link = document.querySelector('link[rel="stylesheet"]');
|
||||||
if (!link) return;
|
const sel = document.getElementById('theme-select');
|
||||||
|
if (!link || !sel) return;
|
||||||
|
|
||||||
// 启动时:根据持久化状态设置 CSS
|
function applyTheme(theme) {
|
||||||
if (localStorage.getItem(STORAGE_KEY) === '1') {
|
if (!CSS_MAP[theme]) return;
|
||||||
link.setAttribute('href', CSS_B);
|
link.setAttribute('href', CSS_MAP[theme]);
|
||||||
|
document.documentElement.setAttribute('data-theme', (theme === 'dark' || theme === 'neo-dark') ? 'dark' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击计数器
|
// 启动时恢复主题
|
||||||
let clickCount = 0, clickTimer = null;
|
const saved = localStorage.getItem(STORAGE_KEY) || 'default';
|
||||||
const trigger = document.querySelector('h1 span');
|
applyTheme(saved);
|
||||||
if (!trigger) return;
|
sel.value = saved;
|
||||||
|
|
||||||
trigger.style.cursor = 'default';
|
// 下拉框切换
|
||||||
trigger.addEventListener('click', function () {
|
sel.addEventListener('change', function () {
|
||||||
clickCount++;
|
const theme = sel.value;
|
||||||
clearTimeout(clickTimer);
|
applyTheme(theme);
|
||||||
clickTimer = setTimeout(() => { clickCount = 0; }, 2000);
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
console.log(`[Theme] Switched → ${theme} (${CSS_MAP[theme]})`);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
// === END EASTER EGG ===
|
// === END THEME SWITCHER ===
|
||||||
|
|
||||||
// Notify parent
|
// Notify parent
|
||||||
postMsg('FRAME_READY');
|
postMsg('FRAME_READY');
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-modal-box {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
.fact-group {
|
.fact-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -80,6 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* ── Base ── */
|
||||||
--bg: #fafafa;
|
--bg: #fafafa;
|
||||||
--bg2: #fff;
|
--bg2: #fff;
|
||||||
--bg3: #f5f5f5;
|
--bg3: #f5f5f5;
|
||||||
@@ -90,7 +95,117 @@
|
|||||||
--bdr2: #e8e8e8;
|
--bdr2: #e8e8e8;
|
||||||
--acc: #1a1a1a;
|
--acc: #1a1a1a;
|
||||||
--hl: #d87a7a;
|
--hl: #d87a7a;
|
||||||
|
--hl2: #d85858;
|
||||||
--hl-soft: rgba(184, 90, 90, .1);
|
--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 {
|
body {
|
||||||
@@ -204,7 +319,7 @@ h1 span {
|
|||||||
|
|
||||||
.stat-warning {
|
.stat-warning {
|
||||||
font-size: .625rem;
|
font-size: .625rem;
|
||||||
color: #ff9800;
|
color: var(--warn);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,17 +421,17 @@ h1 span {
|
|||||||
|
|
||||||
.btn-p {
|
.btn-p {
|
||||||
background: var(--acc);
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-color: var(--acc);
|
border-color: var(--acc);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-p:hover {
|
.btn-p:hover {
|
||||||
background: #555;
|
background: var(--btn-p-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-p:disabled {
|
.btn-p:disabled {
|
||||||
background: #999;
|
background: var(--btn-p-disabled);
|
||||||
border-color: #999;
|
border-color: var(--btn-p-disabled);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: .7;
|
opacity: .7;
|
||||||
}
|
}
|
||||||
@@ -466,20 +581,20 @@ h1 span {
|
|||||||
|
|
||||||
.tag.p {
|
.tag.p {
|
||||||
background: var(--acc);
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-color: var(--acc);
|
border-color: var(--acc);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag.s {
|
.tag.s {
|
||||||
background: var(--hl-soft);
|
background: var(--hl-soft);
|
||||||
border-color: rgba(255, 68, 68, .2);
|
border-color: var(--tag-s-bdr);
|
||||||
color: var(--hl);
|
color: var(--hl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag:hover {
|
.tag:hover {
|
||||||
transform: translateY(-2px);
|
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 {
|
.prof-prog-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width .6s;
|
transition: width .6s;
|
||||||
}
|
}
|
||||||
@@ -769,38 +884,38 @@ h1 span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-broken {
|
.trend-broken {
|
||||||
background: rgba(68, 68, 68, .15);
|
background: var(--trend-broken-bg);
|
||||||
color: #444;
|
color: var(--trend-broken);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-hate {
|
.trend-hate {
|
||||||
background: rgba(139, 0, 0, .15);
|
background: var(--trend-hate-bg);
|
||||||
color: #8b0000;
|
color: var(--trend-hate);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-dislike {
|
.trend-dislike {
|
||||||
background: rgba(205, 92, 92, .15);
|
background: var(--trend-dislike-bg);
|
||||||
color: #cd5c5c;
|
color: var(--trend-dislike);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-stranger {
|
.trend-stranger {
|
||||||
background: rgba(136, 136, 136, .15);
|
background: var(--trend-stranger-bg);
|
||||||
color: #888;
|
color: var(--trend-stranger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-click {
|
.trend-click {
|
||||||
background: rgba(102, 205, 170, .15);
|
background: var(--trend-click-bg);
|
||||||
color: #4a9a7e;
|
color: var(--trend-click);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-close {
|
.trend-close {
|
||||||
background: rgba(235, 106, 106, .15);
|
background: var(--trend-close-bg);
|
||||||
color: var(--hl);
|
color: var(--hl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-merge {
|
.trend-merge {
|
||||||
background: rgba(199, 21, 133, .2);
|
background: var(--trend-merge-bg);
|
||||||
color: #c71585;
|
color: var(--trend-merge);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -913,7 +1028,7 @@ h1 span {
|
|||||||
.modal-bg {
|
.modal-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, .5);
|
background: var(--overlay);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +1079,7 @@ h1 span {
|
|||||||
.modal-close svg {
|
.modal-close svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
color: var(--txt);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -1031,7 +1147,7 @@ h1 span {
|
|||||||
.editor-err {
|
.editor-err {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--hl-soft);
|
background: var(--hl-soft);
|
||||||
border: 1px solid rgba(255, 68, 68, .3);
|
border: 1px solid var(--tag-s-bdr);
|
||||||
color: var(--hl);
|
color: var(--hl);
|
||||||
font-size: .8125rem;
|
font-size: .8125rem;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -1301,24 +1417,24 @@ h1 span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.ready {
|
.status-dot.ready {
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.cached {
|
.status-dot.cached {
|
||||||
background: #3b82f6;
|
background: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.downloading {
|
.status-dot.downloading {
|
||||||
background: #f59e0b;
|
background: var(--downloading);
|
||||||
animation: pulse 1s infinite;
|
animation: pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.error {
|
.status-dot.error {
|
||||||
background: #ef4444;
|
background: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.success {
|
.status-dot.success {
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -1346,7 +1462,7 @@ h1 span {
|
|||||||
|
|
||||||
.progress-inner {
|
.progress-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--hl), #d85858);
|
background: linear-gradient(90deg, var(--hl), var(--hl2));
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
transition: width .3s;
|
transition: width .3s;
|
||||||
@@ -1404,7 +1520,7 @@ h1 span {
|
|||||||
|
|
||||||
.vector-mismatch-warning {
|
.vector-mismatch-warning {
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
color: #f59e0b;
|
color: var(--downloading);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,7 +1574,7 @@ h1 span {
|
|||||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e8e8e8;
|
color: var(--code-txt);
|
||||||
white-space: pre-wrap !important;
|
white-space: pre-wrap !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -1468,7 +1584,7 @@ h1 span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recall-empty {
|
.recall-empty {
|
||||||
color: #999;
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -1555,7 +1671,7 @@ h1 span {
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: var(--acc);
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1648,7 +1764,7 @@ h1 span {
|
|||||||
.hf-code {
|
.hf-code {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
background: #1e1e1e;
|
background: var(--code-bg);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -1657,7 +1773,7 @@ h1 span {
|
|||||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #d4d4d4;
|
color: var(--code-txt);
|
||||||
display: block;
|
display: block;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@@ -1669,7 +1785,7 @@ h1 span {
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: rgba(255, 255, 255, .1);
|
background: rgba(255, 255, 255, .1);
|
||||||
border: 1px solid rgba(255, 255, 255, .2);
|
border: 1px solid rgba(255, 255, 255, .2);
|
||||||
color: #999;
|
color: var(--muted);
|
||||||
font-size: .6875rem;
|
font-size: .6875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -1678,14 +1794,14 @@ h1 span {
|
|||||||
|
|
||||||
.hf-code .copy-btn:hover {
|
.hf-code .copy-btn:hover {
|
||||||
background: rgba(255, 255, 255, .2);
|
background: rgba(255, 255, 255, .2);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-status-badge {
|
.hf-status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
background: rgba(34, 197, 94, .15);
|
background: rgba(34, 197, 94, .15);
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2291,23 +2407,23 @@ h1 span {
|
|||||||
|
|
||||||
/* 分类图标颜色 */
|
/* 分类图标颜色 */
|
||||||
.world-group[data-category="status"] .world-group-title {
|
.world-group[data-category="status"] .world-group-title {
|
||||||
color: #e57373;
|
color: var(--cat-status);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="inventory"] .world-group-title {
|
.world-group[data-category="inventory"] .world-group-title {
|
||||||
color: #64b5f6;
|
color: var(--cat-inventory);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="relation"] .world-group-title {
|
.world-group[data-category="relation"] .world-group-title {
|
||||||
color: #ba68c8;
|
color: var(--cat-relation);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="knowledge"] .world-group-title {
|
.world-group[data-category="knowledge"] .world-group-title {
|
||||||
color: #4db6ac;
|
color: var(--cat-knowledge);
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-group[data-category="rule"] .world-group-title {
|
.world-group[data-category="rule"] .world-group-title {
|
||||||
color: #ffd54f;
|
color: var(--cat-rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
@@ -2444,7 +2560,7 @@ h1 span {
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border: solid #fff;
|
border: solid var(--inv);
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -2740,8 +2856,8 @@ h1 span {
|
|||||||
═══════════════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.debug-log-viewer {
|
.debug-log-viewer {
|
||||||
background: #1a1a1a;
|
background: var(--code-bg);
|
||||||
color: #e0e0e0;
|
color: var(--code-txt);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
font-family: 'Consolas', 'Monaco', 'SF Mono', monospace;
|
||||||
@@ -2756,7 +2872,7 @@ h1 span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recall-empty {
|
.recall-empty {
|
||||||
color: #999;
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -2775,15 +2891,15 @@ h1 span {
|
|||||||
═══════════════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
#recall-log-content .metric-warn {
|
#recall-log-content .metric-warn {
|
||||||
color: #f59e0b;
|
color: var(--downloading);
|
||||||
}
|
}
|
||||||
|
|
||||||
#recall-log-content .metric-error {
|
#recall-log-content .metric-error {
|
||||||
color: #ef4444;
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
#recall-log-content .metric-good {
|
#recall-log-content .metric-good {
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -2825,7 +2941,7 @@ h1 span {
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
background: var(--acc);
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2872,7 +2988,7 @@ h1 span {
|
|||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
background: var(--hl);
|
background: var(--hl);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3103,7 +3219,7 @@ h1 span {
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--acc);
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: .625rem;
|
font-size: .625rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -3304,8 +3420,8 @@ h1 span {
|
|||||||
|
|
||||||
.neo-badge {
|
.neo-badge {
|
||||||
/* Explicitly requested Black Background & White Text */
|
/* Explicitly requested Black Background & White Text */
|
||||||
background: #000;
|
background: var(--acc);
|
||||||
color: #fff;
|
color: var(--inv);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -176,6 +176,22 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Tab 1: Summary Settings -->
|
<!-- Tab 1: Summary Settings -->
|
||||||
<div class="tab-pane active" id="tab-summary">
|
<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 -->
|
<!-- API Config & Gen Params Combined -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section-title">API 配置</div>
|
<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="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||||
<script src="story-summary-ui.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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -449,6 +449,34 @@ async function handleGenerateVectors(vectorCfg) {
|
|||||||
await clearStateVectors(chatId);
|
await clearStateVectors(chatId);
|
||||||
await updateMeta(chatId, { lastChunkFloor: -1, fingerprint });
|
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();
|
const atoms = getStateAtoms();
|
||||||
if (!atoms.length) {
|
if (!atoms.length) {
|
||||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L0", current: 0, total: 0, message: "L0 为空,跳过" });
|
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L0", current: 0, total: 0, message: "L0 为空,跳过" });
|
||||||
@@ -462,29 +490,26 @@ async function handleGenerateVectors(vectorCfg) {
|
|||||||
const batch = atoms.slice(i, i + batchSize);
|
const batch = atoms.slice(i, i + batchSize);
|
||||||
const semTexts = batch.map(a => a.semantic);
|
const semTexts = batch.map(a => a.semantic);
|
||||||
const rTexts = batch.map(a => buildRAggregateText(a));
|
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);
|
||||||
const split = semTexts.length;
|
if (!vectors) break; // cancelled
|
||||||
if (!Array.isArray(vectors) || vectors.length < split * 2) {
|
|
||||||
throw new Error(`embed length mismatch: expect>=${split * 2}, got=${vectors?.length || 0}`);
|
const split = semTexts.length;
|
||||||
}
|
if (!Array.isArray(vectors) || vectors.length < split * 2) {
|
||||||
const semVectors = vectors.slice(0, split);
|
xbLog.error(MODULE_ID, `embed长度不匹配: expect>=${split * 2}, got=${vectors?.length || 0}`);
|
||||||
const rVectors = vectors.slice(split, split + split);
|
continue;
|
||||||
const items = batch.map((a, j) => ({
|
|
||||||
atomId: a.atomId,
|
|
||||||
floor: a.floor,
|
|
||||||
vector: semVectors[j],
|
|
||||||
rVector: rVectors[j] || semVectors[j],
|
|
||||||
}));
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
const semVectors = vectors.slice(0, split);
|
||||||
|
const rVectors = vectors.slice(split, split + split);
|
||||||
|
const items = batch.map((a, j) => ({
|
||||||
|
atomId: a.atomId,
|
||||||
|
floor: a.floor,
|
||||||
|
vector: semVectors[j],
|
||||||
|
rVector: rVectors[j] || semVectors[j],
|
||||||
|
}));
|
||||||
|
await saveStateVectors(chatId, items, fingerprint);
|
||||||
|
l0Completed += batch.length;
|
||||||
|
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L0", current: l0Completed, total: atoms.length });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,22 +541,18 @@ async function handleGenerateVectors(vectorCfg) {
|
|||||||
|
|
||||||
const batch = allChunks.slice(i, i + batchSize);
|
const batch = allChunks.slice(i, i + batchSize);
|
||||||
const texts = batch.map(c => c.text);
|
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);
|
||||||
const items = batch.map((c, j) => ({
|
if (!vectors) break; // cancelled
|
||||||
chunkId: c.chunkId,
|
|
||||||
vector: vectors[j],
|
const items = batch.map((c, j) => ({
|
||||||
}));
|
chunkId: c.chunkId,
|
||||||
await saveChunkVectors(chatId, items, fingerprint);
|
vector: vectors[j],
|
||||||
l1Vectors = l1Vectors.concat(items);
|
}));
|
||||||
l1Completed += batch.length;
|
await saveChunkVectors(chatId, items, fingerprint);
|
||||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: l1Completed, total: allChunks.length });
|
l1Vectors = l1Vectors.concat(items);
|
||||||
} catch (e) {
|
l1Completed += batch.length;
|
||||||
if (e?.name === "AbortError") break;
|
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: l1Completed, total: allChunks.length });
|
||||||
xbLog.error(MODULE_ID, "L1 向量化失败", e);
|
|
||||||
vectorCancelled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,21 +576,17 @@ async function handleGenerateVectors(vectorCfg) {
|
|||||||
|
|
||||||
const batch = l2Pairs.slice(i, i + batchSize);
|
const batch = l2Pairs.slice(i, i + batchSize);
|
||||||
const texts = batch.map(p => p.text);
|
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);
|
||||||
const items = batch.map((p, idx) => ({
|
if (!vectors) break; // cancelled
|
||||||
eventId: p.id,
|
|
||||||
vector: vectors[idx],
|
const items = batch.map((p, idx) => ({
|
||||||
}));
|
eventId: p.id,
|
||||||
await saveEventVectorsToDb(chatId, items, fingerprint);
|
vector: vectors[idx],
|
||||||
l2Completed += batch.length;
|
}));
|
||||||
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: l2Completed, total: l2Pairs.length });
|
await saveEventVectorsToDb(chatId, items, fingerprint);
|
||||||
} catch (e) {
|
l2Completed += batch.length;
|
||||||
if (e?.name === "AbortError") break;
|
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: l2Completed, total: l2Pairs.length });
|
||||||
xbLog.error(MODULE_ID, "L2 向量化失败", e);
|
|
||||||
vectorCancelled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
import { xbLog } from '../../../../core/debug-core.js';
|
import { xbLog } from '../../../../core/debug-core.js';
|
||||||
import { getVectorConfig } from '../../data/config.js';
|
import { getVectorConfig } from '../../data/config.js';
|
||||||
|
import { getApiKey } from './siliconflow.js';
|
||||||
|
|
||||||
const MODULE_ID = 'vector-llm-service';
|
const MODULE_ID = 'vector-llm-service';
|
||||||
const SILICONFLOW_API_URL = 'https://api.siliconflow.cn/v1';
|
const SILICONFLOW_API_URL = 'https://api.siliconflow.cn/v1';
|
||||||
@@ -40,8 +41,7 @@ export async function callLLM(messages, options = {}) {
|
|||||||
const mod = getStreamingModule();
|
const mod = getStreamingModule();
|
||||||
if (!mod) throw new Error('Streaming module not ready');
|
if (!mod) throw new Error('Streaming module not ready');
|
||||||
|
|
||||||
const cfg = getVectorConfig();
|
const apiKey = getApiKey() || '';
|
||||||
const apiKey = cfg?.online?.key || '';
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('L0 requires siliconflow API key');
|
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 BASE_URL = 'https://api.siliconflow.cn';
|
||||||
const EMBEDDING_MODEL = 'BAAI/bge-m3';
|
const EMBEDDING_MODEL = 'BAAI/bge-m3';
|
||||||
|
|
||||||
export function getApiKey() {
|
// ★ 多 Key 轮询状态
|
||||||
|
let _keyIndex = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 解析所有 Key(支持逗号、分号、竖线、换行分隔)
|
||||||
|
*/
|
||||||
|
function parseKeys() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('summary_panel_config');
|
const raw = localStorage.getItem('summary_panel_config');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(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 { }
|
} 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 = {}) {
|
export async function embed(texts, options = {}) {
|
||||||
if (!texts?.length) return [];
|
if (!texts?.length) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -181,14 +181,83 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
|||||||
// ★ Phase 1: 收集所有新提取的 atoms(不向量化)
|
// ★ Phase 1: 收集所有新提取的 atoms(不向量化)
|
||||||
const allNewAtoms = [];
|
const allNewAtoms = [];
|
||||||
|
|
||||||
// ★ 30 并发批次处理
|
// ★ 限流检测:连续失败 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);
|
||||||
|
|
||||||
|
active++;
|
||||||
|
if (active > peakActive) peakActive = active;
|
||||||
|
if (DEBUG_CONCURRENCY && (idx % 10 === 0)) {
|
||||||
|
xbLog.info(MODULE_ID, `L0 pool start idx=${idx} active=${active} peak=${peakActive} worker=${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const atoms = await extractAtomsForRound(pair.userMsg, pair.aiMsg, floor, { timeout: 20000 });
|
||||||
|
|
||||||
|
if (extractionCancelled) return;
|
||||||
|
|
||||||
|
if (atoms == null) {
|
||||||
|
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);
|
||||||
|
allNewAtoms.push(...atoms);
|
||||||
|
|
||||||
|
setL0FloorStatus(floor, { status: 'ok', atoms: atoms.length });
|
||||||
|
builtAtoms += atoms.length;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (extractionCancelled) return;
|
||||||
|
|
||||||
|
setL0FloorStatus(floor, {
|
||||||
|
status: 'fail',
|
||||||
|
attempts: (prev?.attempts || 0) + 1,
|
||||||
|
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) {
|
||||||
|
completed++;
|
||||||
|
onProgress?.(`提取: ${completed}/${total}`, completed, total);
|
||||||
|
}
|
||||||
|
if (DEBUG_CONCURRENCY && (completed % 25 === 0 || completed === total)) {
|
||||||
|
const elapsed = Math.max(1, Math.round(performance.now() - tStart));
|
||||||
|
xbLog.info(MODULE_ID, `L0 pool progress=${completed}/${total} active=${active} peak=${peakActive} elapsedMs=${elapsed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ★ 并发池处理(保持固定并发度)
|
||||||
const poolSize = Math.min(CONCURRENCY, pendingPairs.length);
|
const poolSize = Math.min(CONCURRENCY, pendingPairs.length);
|
||||||
let nextIndex = 0;
|
let nextIndex = 0;
|
||||||
let started = 0;
|
let started = 0;
|
||||||
const runWorker = async (workerId) => {
|
const runWorker = async (workerId) => {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (extractionCancelled) return;
|
if (extractionCancelled || rateLimited) return;
|
||||||
const idx = nextIndex++;
|
const idx = nextIndex++;
|
||||||
if (idx >= pendingPairs.length) return;
|
if (idx >= pendingPairs.length) return;
|
||||||
|
|
||||||
@@ -198,57 +267,9 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
|
|||||||
await new Promise(r => setTimeout(r, stagger * STAGGER_DELAY));
|
await new Promise(r => setTimeout(r, stagger * STAGGER_DELAY));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extractionCancelled) return;
|
if (extractionCancelled || rateLimited) return;
|
||||||
|
|
||||||
const floor = pair.aiFloor;
|
await processPair(pair, idx, workerId);
|
||||||
const prev = getL0FloorStatus(floor);
|
|
||||||
|
|
||||||
active++;
|
|
||||||
if (active > peakActive) peakActive = active;
|
|
||||||
if (DEBUG_CONCURRENCY && (idx % 10 === 0)) {
|
|
||||||
xbLog.info(MODULE_ID, `L0 pool start idx=${idx} active=${active} peak=${peakActive} worker=${workerId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const atoms = await extractAtomsForRound(pair.userMsg, pair.aiMsg, floor, { timeout: 20000 });
|
|
||||||
|
|
||||||
if (extractionCancelled) return;
|
|
||||||
|
|
||||||
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);
|
|
||||||
saveStateAtoms(atoms);
|
|
||||||
// Phase 1: 只收集,不向量化
|
|
||||||
allNewAtoms.push(...atoms);
|
|
||||||
|
|
||||||
setL0FloorStatus(floor, { status: 'ok', atoms: atoms.length });
|
|
||||||
builtAtoms += atoms.length;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (extractionCancelled) return;
|
|
||||||
|
|
||||||
setL0FloorStatus(floor, {
|
|
||||||
status: 'fail',
|
|
||||||
attempts: (prev?.attempts || 0) + 1,
|
|
||||||
reason: String(e?.message || e).replace(/\s+/g, ' ').slice(0, 120),
|
|
||||||
});
|
|
||||||
failed++;
|
|
||||||
} finally {
|
|
||||||
active--;
|
|
||||||
if (!extractionCancelled) {
|
|
||||||
completed++;
|
|
||||||
onProgress?.(`提取: ${completed}/${total}`, completed, total);
|
|
||||||
}
|
|
||||||
if (DEBUG_CONCURRENCY && (completed % 25 === 0 || completed === total)) {
|
|
||||||
const elapsed = Math.max(1, Math.round(performance.now() - tStart));
|
|
||||||
xbLog.info(MODULE_ID, `L0 pool progress=${completed}/${total} active=${active} peak=${peakActive} elapsedMs=${elapsed}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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}`);
|
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 {
|
try {
|
||||||
saveMetadataDebounced?.();
|
saveMetadataDebounced?.();
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|||||||
Reference in New Issue
Block a user