Update story-summary modules

This commit is contained in:
RT15548
2026-02-16 17:25:34 +08:00
parent 22d3002786
commit 3ea3a62cad
9 changed files with 1062 additions and 263 deletions

View 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;
}

View File

@@ -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,36 +85,127 @@
--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 {
--bg: #111111;
--bg2: #222222;
--bg3: #333333;
--txt: #ffffff;
--txt2: #eeeeee;
--txt3: #cccccc;
:root[data-theme="dark"] {
/* ── Base ── */
--bg: #111111;
--bg2: #222222;
--bg3: #333333;
--txt: #ffffff;
--txt2: #eeeeee;
--txt3: #cccccc;
--bdr: #ffffff;
--bdr2: #ffffff;
/* ── 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;
--acc: #ffffff;
--hl: #ff6b6b;
--hl-soft: #442222;
}
/* ── 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;

View File

@@ -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');

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,29 +490,26 @@ 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 split = semTexts.length;
if (!Array.isArray(vectors) || vectors.length < split * 2) {
throw new Error(`embed length mismatch: expect>=${split * 2}, got=${vectors?.length || 0}`);
}
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 });
} catch (e) {
if (e?.name === "AbortError") break;
xbLog.error(MODULE_ID, "L0 向量化失败", e);
vectorCancelled = true;
break;
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) {
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);
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 texts = batch.map(c => c.text);
try {
const vectors = await embed(texts, vectorCfg, { signal: vectorAbortController.signal });
const items = batch.map((c, j) => ({
chunkId: c.chunkId,
vector: vectors[j],
}));
await saveChunkVectors(chatId, items, fingerprint);
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;
}
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],
}));
await saveChunkVectors(chatId, items, fingerprint);
l1Vectors = l1Vectors.concat(items);
l1Completed += batch.length;
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: l1Completed, total: allChunks.length });
}
}
@@ -555,21 +576,17 @@ 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 items = batch.map((p, idx) => ({
eventId: p.id,
vector: vectors[idx],
}));
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;
}
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],
}));
await saveEventVectorsToDb(chatId, items, fingerprint);
l2Completed += batch.length;
postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: l2Completed, total: l2Pairs.length });
}
}

View File

@@ -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');
}

View File

@@ -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 [];

View File

@@ -181,14 +181,83 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress, options
// ★ Phase 1: 收集所有新提取的 atoms不向量化
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);
let nextIndex = 0;
let started = 0;
const runWorker = async (workerId) => {
while (true) {
if (extractionCancelled) return;
if (extractionCancelled || rateLimited) return;
const idx = nextIndex++;
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));
}
if (extractionCancelled) return;
if (extractionCancelled || rateLimited) return;
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');
}
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}`);
}
}
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 { }