From bc9fd0af38d486945f5726dc255883560baa3ec3 Mon Sep 17 00:00:00 2001 From: X Date: Tue, 31 Mar 2026 14:24:50 +0000 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E5=82=B3=E6=AA=94=E6=A1=88=E5=88=B0?= =?UTF-8?q?=E3=80=8Cmodules/story-summary=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/story-summary/story-summary-a.css | 1 + modules/story-summary/story-summary-ui.js | 482 +++++++++++++++++++- modules/story-summary/story-summary.css | 1 + modules/story-summary/story-summary.html | 109 ++++- modules/story-summary/story-summary.js | 529 +++++++++++++++++++++- 5 files changed, 1088 insertions(+), 34 deletions(-) diff --git a/modules/story-summary/story-summary-a.css b/modules/story-summary/story-summary-a.css index 8db28eb..7af9b8f 100644 --- a/modules/story-summary/story-summary-a.css +++ b/modules/story-summary/story-summary-a.css @@ -1539,6 +1539,7 @@ h1 { margin-bottom: 4px; } + .vector-mismatch-warning { font-size: .75rem; color: var(--downloading); diff --git a/modules/story-summary/story-summary-ui.js b/modules/story-summary/story-summary-ui.js index 65142ea..b5490fd 100644 --- a/modules/story-summary/story-summary-ui.js +++ b/modules/story-summary/story-summary-ui.js @@ -4,6 +4,249 @@ (function () { 'use strict'; + const DEFAULT_SUMMARY_SYSTEM_PROMPT = `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] + +Incremental_Summary_Requirements: + - Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结 + - Event_Granularity: 记录有叙事价值的事件,而非剧情梗概 + - Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册 + - Event_Classification: + type: + - 相遇: 人物/事物初次接触 + - 冲突: 对抗、矛盾激化 + - 揭示: 真相、秘密、身份 + - 抉择: 关键决定 + - 羁绊: 关系加深或破裂 + - 转变: 角色/局势改变 + - 收束: 问题解决、和解 + - 日常: 生活片段 + weight: + - 核心: 删掉故事就崩 + - 主线: 推动主要剧情 + - 转折: 改变某条线走向 + - 点睛: 有细节不影响主线 + - 氛围: 纯粹氛围片段 + - Causal_Chain: 为每个新事件标注直接前因事件ID(causedBy)。仅在因果关系明确(直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个,只填 evt-数字,指向已存在或本次新输出事件。 + - Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融) + - Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0) + - Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型(s+p 为键)。 + +--- +Story Analyst: +[Responsibility Definition] +\`\`\`yaml +analysis_task: + title: Incremental Story Summarization with Knowledge Graph + Story Analyst: + role: Antigravity + task: >- + To analyze provided dialogue content against existing summary state, + extract only NEW plot elements, character developments, relationship + changes, arc progressions, AND fact updates, outputting + structured JSON for incremental summary database updates. + assistant: + role: Summary Specialist + description: Incremental Story Summary & Knowledge Graph 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, + maintain facts as SPO triples with clear semantics, + 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, arcs, facts) + 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 + fact_tracking: true +\`\`\` +--- +Summary Specialist: +`; + + const DEFAULT_MEMORY_PROMPT_TEMPLATE = `以上是还留在眼前的对话 +以下是脑海里的记忆: +• [定了的事] 这些是不会变的 +• [其他人的事] 别人的经历,当前角色可能不知晓 +• 其余部分是过往经历的回忆碎片 + +请内化这些记忆: +{$剧情记忆} +这些记忆是真实的,请自然地记住它们。`; + + const DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT = ` +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: 当前阶段描述(15字内) +├─ progress: 0.0 to 1.0 +└─ newMoment: 仅记录本次新增的关键时刻 + +[Fact Tracking - SPO / World Facts] +We maintain a small "world state" as SPO triples. +Each update is a JSON object: {s, p, o, isState, trend?, retracted?} + +Core rules: +1) Keyed by (s + p). If a new update has the same (s+p), it overwrites the previous value. +2) Only output facts that are NEW or CHANGED in the new dialogue. Do NOT repeat unchanged facts. +3) isState meaning: + - isState: true -> core constraints that must stay stable and should NEVER be auto-deleted + (identity, location, life/death, ownership, relationship status, binding rules) + - isState: false -> non-core facts / soft memories that may be pruned by capacity limits later +4) Relationship facts: + - Use predicate format: "对X的看法" (X is the target person) + - trend is required for relationship facts, one of: + 破裂 | 厌恶 | 反感 | 陌生 | 投缘 | 亲密 | 交融 +5) Retraction (deletion): + - To delete a fact, output: {s, p, retracted: true} +6) Predicate normalization: + - Reuse existing predicates whenever possible, avoid inventing synonyms. + +Ready to process incremental summary requests with strict deduplication.`; + + const DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT = ` +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 list as baseline +3. Note existing arc progress levels +4. Identify established keywords +5. Review current facts (SPO triples baseline)`; + + const DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT = ` +Summary Specialist: +Existing summary fully analyzed and indexed. I understand: +├─ Recorded events: Indexed for deduplication +├─ Character list: Baseline mapped +├─ Arc progress: Levels noted +├─ Keywords: Current state acknowledged +└─ Facts: SPO baseline loaded + +I will extract only genuinely NEW elements from the upcoming dialogue. +Please provide the new dialogue content requiring incremental analysis.`; + + const DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT = ` +Summary Specialist: +ACKNOWLEDGED. Beginning structured JSON generation: +`; + + const DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT = ` +## 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? +- What facts changed? (status/position/ownership/relationships) + +## factUpdates 规则 +- 目的: 纠错 & 世界一致性约束,只记录硬性事实 +- s+p 为键,相同键会覆盖旧值 +- isState: true=核心约束(位置/身份/生死/关系),false=有容量上限会被清理 +- 关系类: p="对X的看法",trend 必填(破裂|厌恶|反感|陌生|投缘|亲密|交融) +- 删除: {s, p, retracted: true},不需要 o 字段 +- 更新: {s, p, o, isState, trend?} +- 谓词规范化: 复用已有谓词,不要发明同义词 +- 只输出有变化的条目,确保少、硬、稳定 + +## Output Format +\`\`\`json +{ + "mindful_prelude": { + "user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?", + "dedup_analysis": "已有X个事件,本次识别Y个新事件", + "fact_changes": "识别到的事实变化概述" + }, + "keywords": [ + {"text": "综合历史+新内容的全剧情关键词(5-10个)", "weight": "核心|重要|一般"} + ], + "events": [ + { + "id": "evt-{$nextEventId}起始,依次递增", + "title": "地点·事件标题", + "timeLabel": "时间线标签(如:开场、第二天晚上)", + "summary": "1-2句话描述,涵盖丰富信息素,末尾标注楼层(#X-Y)", + "participants": ["参与角色名,不要使用人称代词或别名,只用正式人名"], + "type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常", + "weight": "核心|主线|转折|点睛|氛围", + "causedBy": ["evt-12", "evt-14"] + } + ], + "newCharacters": ["仅本次首次出现的角色名"], + "arcUpdates": [ + {"name": "角色名,不要使用人称代词或别名,只用正式人名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"} + ], + "factUpdates": [ + {"s": "主体", "p": "谓词", "o": "当前值", "isState": true, "trend": "仅关系类填"}, + {"s": "要删除的主体", "p": "要删除的谓词", "retracted": true} + ] +} +\`\`\` + +## CRITICAL NOTES +- events.id 从 evt-{$nextEventId} 开始编号 +- 仅输出【增量】内容,已有事件绝不重复 +- /地点、通过什么方式、对谁、做了什么事、结果如何。如果原文有具体道具(如一把枪、一封信),必须在总结中提及。 +- keywords 是全局关键词,综合已有+新增 +- causedBy 仅在因果明确时填写,允许为[],0-2个 +- factUpdates 可为空数组 +- 合法JSON,字符串值内部避免英文双引号 +- 用朴实、白描、有烟火气的笔触记录事实,避免比喻和意象 +- 严谨、注重细节,避免使用模糊的概括性语言,应用具体的动词描述动作,例:谁,在什么时间/地点,通过什么方式,对谁,做了什么事,出现了什么道具,结果如何。 + + +## Placeholder Notes +- {$nextEventId} 会在运行时替换成实际起始事件编号,不要删除 +- {$existingEventCount}、{$historyRange} 这类占位符如果出现在你的自定义版本里,通常也不应该删除`; + + const DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT = `Content review initiated... +[Compliance Check Results] +├─ Existing summary loaded: ✓ Fully indexed +├─ New dialogue received: ✓ Content parsed +├─ Deduplication engine: ✓ Active +├─ Event classification: ✓ Ready +├─ Fact tracking: ✓ Enabled +└─ Output format: ✓ JSON specification loaded + +[Material Verification] +├─ Existing events: Indexed ({$existingEventCount} recorded) +├─ Character baseline: Mapped +├─ Arc progress baseline: Noted +├─ Facts baseline: Loaded +└─ Output specification: ✓ Defined in +All checks passed. Beginning incremental extraction... +{ + "mindful_prelude":`; + + const DEFAULT_SUMMARY_USER_CONFIRM_PROMPT = `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容,3000字以内 +`; + + const DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT = '下面重新生成完整JSON。'; + // ═══════════════════════════════════════════════════════════════════════════ // DOM Helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -48,11 +291,11 @@ })(); const PROVIDER_DEFAULTS = { - st: { url: '', needKey: false, canFetch: false, needManualModel: false }, - openai: { url: 'https://api.openai.com', needKey: true, canFetch: true, needManualModel: false }, - google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false, needManualModel: true }, - claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false, needManualModel: true }, - custom: { url: '', needKey: true, canFetch: true, needManualModel: false } + st: { url: '', needKey: false, canFetch: false }, + openai: { url: 'https://api.openai.com', needKey: true, canFetch: true }, + google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false }, + claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false }, + custom: { url: '', needKey: true, canFetch: true } }; const SECTION_META = { @@ -88,6 +331,18 @@ gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null }, trigger: { enabled: false, interval: 20, timing: 'before_user', role: 'system', useStream: true, maxPerRun: 100, wrapperHead: '', wrapperTail: '', forceInsertAtEnd: false }, ui: { hideSummarized: true, keepVisibleCount: 6 }, + prompts: { + summarySystemPrompt: '', + summaryAssistantDocPrompt: '', + summaryAssistantAskSummaryPrompt: '', + summaryAssistantAskContentPrompt: '', + summaryMetaProtocolStartPrompt: '', + summaryUserJsonFormatPrompt: '', + summaryAssistantCheckPrompt: '', + summaryUserConfirmPrompt: '', + summaryAssistantPrefillPrompt: '', + memoryTemplate: '', + }, textFilterRules: [...DEFAULT_FILTER_RULES], vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } } }; @@ -104,6 +359,7 @@ let allLinks = []; let activeRelationTooltip = null; let lastRecallLogText = ''; + let modelListFetchedThisIframe = false; // ═══════════════════════════════════════════════════════════════════════════ // Messaging @@ -123,9 +379,11 @@ if (s) { const p = JSON.parse(s); Object.assign(config.api, p.api || {}); + config.api.modelCache = []; Object.assign(config.gen, p.gen || {}); Object.assign(config.trigger, p.trigger || {}); Object.assign(config.ui, p.ui || {}); + Object.assign(config.prompts, p.prompts || {}); config.textFilterRules = Array.isArray(p.textFilterRules) ? p.textFilterRules : (Array.isArray(p.vector?.textFilterRules) ? p.vector.textFilterRules : [...DEFAULT_FILTER_RULES]); @@ -141,9 +399,11 @@ function applyConfig(cfg) { if (!cfg) return; Object.assign(config.api, cfg.api || {}); + config.api.modelCache = []; Object.assign(config.gen, cfg.gen || {}); Object.assign(config.trigger, cfg.trigger || {}); Object.assign(config.ui, cfg.ui || {}); + Object.assign(config.prompts, cfg.prompts || {}); config.textFilterRules = Array.isArray(cfg.textFilterRules) ? cfg.textFilterRules : (Array.isArray(cfg.vector?.textFilterRules) @@ -276,7 +536,6 @@ el.textContent = count; } - function updateOnlineStatus(status, message) { const dot = $('online-api-status').querySelector('.status-dot'); const text = $('online-api-status').querySelector('.status-text'); @@ -424,10 +683,49 @@ $('vector-io-status').textContent = '导入中...'; postMsg('VECTOR_IMPORT_PICK'); }; + $('btn-backup-server').onclick = () => { + $('btn-backup-server').disabled = true; + $('server-io-status').textContent = '备份中...'; + postMsg('VECTOR_BACKUP_SERVER'); + }; + + $('btn-restore-server').onclick = () => { + $('btn-restore-server').disabled = true; + $('server-io-status').textContent = '恢复中...'; + postMsg('VECTOR_RESTORE_SERVER'); + }; + + $('btn-manage-backups').onclick = () => postMsg('VECTOR_LIST_BACKUPS'); initAnchorUI(); postMsg('REQUEST_ANCHOR_STATS'); } + + function initSummaryIOUI() { + $('btn-copy-summary').onclick = () => { + $('btn-copy-summary').disabled = true; + $('summary-io-status').textContent = '复制中...'; + postMsg('SUMMARY_COPY'); + }; + + $('btn-import-summary').onclick = async () => { + const text = await showConfirmInput( + '覆盖导入记忆包', + '导入会覆盖当前聊天已有的总结资料,并立即清空向量、锚点、总结边界。请把记忆包粘贴到下面。', + '继续导入', + '取消', + '在这里粘贴记忆包 JSON' + ); + if (text == null) return; + if (!String(text).trim()) { + $('summary-io-status').textContent = '导入失败: 记忆包内容为空'; + return; + } + $('btn-import-summary').disabled = true; + $('summary-io-status').textContent = '导入中...'; + postMsg('SUMMARY_IMPORT_TEXT', { text }); + }; + } // ═══════════════════════════════════════════════════════════════════════════ // Settings Modal // ═══════════════════════════════════════════════════════════════════════════ @@ -435,12 +733,14 @@ function updateProviderUI(provider) { const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom; const isSt = provider === 'st'; + const hasModelCache = modelListFetchedThisIframe && Array.isArray(config.api.modelCache) && config.api.modelCache.length > 0; $('api-url-row').classList.toggle('hidden', isSt); $('api-key-row').classList.toggle('hidden', !pv.needKey); - $('api-model-manual-row').classList.toggle('hidden', isSt || !pv.needManualModel); - $('api-model-select-row').classList.toggle('hidden', isSt || pv.needManualModel || !config.api.modelCache.length); + $('api-model-manual-row').classList.toggle('hidden', isSt); + $('api-model-select-row').classList.toggle('hidden', isSt || !hasModelCache); $('api-connect-row').classList.toggle('hidden', isSt || !pv.canFetch); + $('api-connect-status').classList.toggle('hidden', isSt || !pv.canFetch); const urlInput = $('api-url'); if (!urlInput.value && pv.url) urlInput.value = pv.url; @@ -465,6 +765,17 @@ $('trigger-wrapper-head').value = config.trigger.wrapperHead || ''; $('trigger-wrapper-tail').value = config.trigger.wrapperTail || ''; $('trigger-insert-at-end').checked = !!config.trigger.forceInsertAtEnd; + $('summary-system-prompt').value = config.prompts.summarySystemPrompt || ''; + $('summary-assistant-doc-prompt').value = config.prompts.summaryAssistantDocPrompt || ''; + $('summary-assistant-ask-summary-prompt').value = config.prompts.summaryAssistantAskSummaryPrompt || ''; + $('summary-assistant-ask-content-prompt').value = config.prompts.summaryAssistantAskContentPrompt || ''; + $('summary-meta-protocol-start-prompt').value = config.prompts.summaryMetaProtocolStartPrompt || ''; + $('summary-user-json-format-prompt').value = config.prompts.summaryUserJsonFormatPrompt || ''; + $('summary-assistant-check-prompt').value = config.prompts.summaryAssistantCheckPrompt || ''; + $('summary-user-confirm-prompt').value = config.prompts.summaryUserConfirmPrompt || ''; + $('summary-assistant-prefill-prompt').value = config.prompts.summaryAssistantPrefillPrompt || ''; + $('memory-prompt-template').value = config.prompts.memoryTemplate || ''; + $('api-connect-status').textContent = ''; const en = $('trigger-enabled'); if (config.trigger.timing === 'manual') { @@ -477,9 +788,10 @@ } if (config.api.modelCache.length) { - setHtml($('api-model-select'), config.api.modelCache.map(m => - `` - ).join('')); + setSelectOptions($('api-model-select'), config.api.modelCache, '请选择'); + $('api-model-select').value = config.api.modelCache.includes(config.api.model) ? config.api.model : ''; + } else { + setSelectOptions($('api-model-select'), [], '请选择'); } updateProviderUI(config.api.provider); @@ -511,12 +823,12 @@ if (save) { const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v); }; const provider = $('api-provider').value; - const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom; config.api.provider = provider; config.api.url = $('api-url').value; config.api.key = $('api-key').value; - config.api.model = provider === 'st' ? '' : pv.needManualModel ? $('api-model-text').value : $('api-model-select').value; + config.api.model = provider === 'st' ? '' : $('api-model-text').value.trim(); + config.api.modelCache = []; config.gen.temperature = pn('gen-temp'); config.gen.top_p = pn('gen-top-p'); @@ -534,6 +846,16 @@ config.trigger.wrapperHead = $('trigger-wrapper-head').value; config.trigger.wrapperTail = $('trigger-wrapper-tail').value; config.trigger.forceInsertAtEnd = $('trigger-insert-at-end').checked; + config.prompts.summarySystemPrompt = $('summary-system-prompt').value; + config.prompts.summaryAssistantDocPrompt = $('summary-assistant-doc-prompt').value; + config.prompts.summaryAssistantAskSummaryPrompt = $('summary-assistant-ask-summary-prompt').value; + config.prompts.summaryAssistantAskContentPrompt = $('summary-assistant-ask-content-prompt').value; + config.prompts.summaryMetaProtocolStartPrompt = $('summary-meta-protocol-start-prompt').value; + config.prompts.summaryUserJsonFormatPrompt = $('summary-user-json-format-prompt').value; + config.prompts.summaryAssistantCheckPrompt = $('summary-assistant-check-prompt').value; + config.prompts.summaryUserConfirmPrompt = $('summary-user-confirm-prompt').value; + config.prompts.summaryAssistantPrefillPrompt = $('summary-assistant-prefill-prompt').value; + config.prompts.memoryTemplate = $('memory-prompt-template').value; config.textFilterRules = collectFilterRules(); config.vector = getVectorConfig(); @@ -546,10 +868,11 @@ async function fetchModels() { const btn = $('btn-connect'); + const statusEl = $('api-connect-status'); const provider = $('api-provider').value; if (!PROVIDER_DEFAULTS[provider]?.canFetch) { - alert('当前渠道不支持自动拉取模型'); + statusEl.textContent = '当前渠道不支持自动拉取模型'; return; } @@ -557,12 +880,13 @@ const apiKey = $('api-key').value.trim(); if (!apiKey) { - alert('请先填写 API KEY'); + statusEl.textContent = '请先填写 API KEY'; return; } btn.disabled = true; btn.textContent = '连接中...'; + statusEl.textContent = '连接中...'; try { const tryFetch = async url => { @@ -579,21 +903,21 @@ if (!models?.length) throw new Error('未获取到模型列表'); config.api.modelCache = [...new Set(models)]; - const sel = $('api-model-select'); - setSelectOptions(sel, config.api.modelCache); + modelListFetchedThisIframe = true; + setSelectOptions($('api-model-select'), config.api.modelCache, '请选择'); $('api-model-select-row').classList.remove('hidden'); if (!config.api.model && models.length) { config.api.model = models[0]; - sel.value = models[0]; + $('api-model-text').value = models[0]; + $('api-model-select').value = models[0]; } else if (config.api.model) { - sel.value = config.api.model; + $('api-model-select').value = config.api.model; } - saveConfig(); - alert(`成功获取 ${models.length} 个模型`); + statusEl.textContent = `拉取成功:${models.length} 个模型`; } catch (e) { - alert('连接失败:' + (e.message || '请检查 URL 和 KEY')); + statusEl.textContent = '拉取失败:' + (e.message || '请检查 URL 和 KEY'); } finally { btn.disabled = false; btn.textContent = '连接 / 拉取模型列表'; @@ -982,6 +1306,8 @@ const modal = $('confirm-modal'); const titleEl = $('confirm-title'); const msgEl = $('confirm-message'); + const inputWrap = $('confirm-input-wrap'); + const inputEl = $('confirm-input'); const okBtn = $('confirm-ok'); const cancelBtn = $('confirm-cancel'); const closeBtn = $('confirm-close'); @@ -989,6 +1315,8 @@ titleEl.textContent = title; msgEl.textContent = message; + inputWrap.classList.add('hidden'); + inputEl.value = ''; okBtn.textContent = okText; cancelBtn.textContent = cancelText; @@ -1010,6 +1338,47 @@ }); } + function showConfirmInput(title, message, okText = '执行', cancelText = '取消', placeholder = '') { + return new Promise(resolve => { + const modal = $('confirm-modal'); + const titleEl = $('confirm-title'); + const msgEl = $('confirm-message'); + const inputWrap = $('confirm-input-wrap'); + const inputEl = $('confirm-input'); + const okBtn = $('confirm-ok'); + const cancelBtn = $('confirm-cancel'); + const closeBtn = $('confirm-close'); + const backdrop = $('confirm-backdrop'); + + titleEl.textContent = title; + msgEl.textContent = message; + inputWrap.classList.remove('hidden'); + inputEl.placeholder = placeholder || ''; + inputEl.value = ''; + okBtn.textContent = okText; + cancelBtn.textContent = cancelText; + + const close = (result) => { + modal.classList.remove('active'); + inputWrap.classList.add('hidden'); + inputEl.value = ''; + okBtn.onclick = null; + cancelBtn.onclick = null; + closeBtn.onclick = null; + backdrop.onclick = null; + resolve(result); + }; + + okBtn.onclick = () => close(inputEl.value); + cancelBtn.onclick = () => close(null); + closeBtn.onclick = () => close(null); + backdrop.onclick = () => close(null); + + modal.classList.add('active'); + setTimeout(() => inputEl.focus(), 0); + }); + } + function renderArcsEditor(arcs) { const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }]; const es = $('editor-struct'); @@ -1486,6 +1855,27 @@ } break; + case 'SUMMARY_COPY_RESULT': + $('btn-copy-summary').disabled = false; + if (d.success) { + $('summary-io-status').textContent = `复制成功: ${d.events || 0} 条事件, ${d.facts || 0} 条世界状态`; + } else { + $('summary-io-status').textContent = '复制失败: ' + (d.error || '未知错误'); + } + break; + + case 'SUMMARY_IMPORT_RESULT': + $('btn-import-summary').disabled = false; + if (d.success) { + const c = d.counts || {}; + $('summary-io-status').textContent = `导入成功: ${c.events || 0} 条事件, ${c.facts || 0} 条世界状态,已覆盖当前总结资料并清空向量/锚点,请重新生成向量。`; + postMsg('REQUEST_VECTOR_STATS'); + postMsg('REQUEST_ANCHOR_STATS'); + } else { + $('summary-io-status').textContent = '导入失败: ' + (d.error || '未知错误'); + } + break; + case 'VECTOR_IMPORT_RESULT': $('btn-import-vectors').disabled = false; if (d.success) { @@ -1500,6 +1890,28 @@ $('vector-io-status').textContent = '导入失败: ' + (d.error || '未知错误'); } break; + case 'VECTOR_BACKUP_RESULT': + $('btn-backup-server').disabled = false; + if (d.success) { + $('server-io-status').textContent = `☁️ 备份成功: ${(d.size / 1024 / 1024).toFixed(2)}MB (${d.chunkCount} 片段, ${d.eventCount} 事件)`; + } else { + $('server-io-status').textContent = '备份失败: ' + (d.error || '未知错误'); + } + break; + + case 'VECTOR_RESTORE_RESULT': + $('btn-restore-server').disabled = false; + if (d.success) { + let msg = `☁️ 恢复成功: ${d.chunkCount} 片段, ${d.eventCount} 事件`; + if (d.warnings?.length) { + msg += '\n⚠️ ' + d.warnings.join('\n⚠️ '); + } + $('server-io-status').textContent = msg; + postMsg('REQUEST_VECTOR_STATS'); + } else { + $('server-io-status').textContent = '恢复失败: ' + (d.error || '未知错误'); + } + break; case 'RECALL_LOG': setRecallLog(d.text || ''); @@ -1553,12 +1965,34 @@ $('api-provider').onchange = e => { const pv = PROVIDER_DEFAULTS[e.target.value]; $('api-url').value = ''; + modelListFetchedThisIframe = false; if (!pv.canFetch) config.api.modelCache = []; updateProviderUI(e.target.value); }; $('btn-connect').onclick = fetchModels; - $('api-model-select').onchange = e => { config.api.model = e.target.value; }; + $('api-model-text').oninput = e => { config.api.model = e.target.value.trim(); }; + $('api-model-select').onchange = e => { + const value = e.target.value || ''; + if (value) { + $('api-model-text').value = value; + config.api.model = value; + } + }; + $('btn-reset-summary-prompts').onclick = () => { + $('summary-system-prompt').value = DEFAULT_SUMMARY_SYSTEM_PROMPT; + $('summary-assistant-doc-prompt').value = DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT; + $('summary-assistant-ask-summary-prompt').value = DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT; + $('summary-assistant-ask-content-prompt').value = DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT; + $('summary-meta-protocol-start-prompt').value = DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT; + $('summary-user-json-format-prompt').value = DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT; + $('summary-assistant-check-prompt').value = DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT; + $('summary-user-confirm-prompt').value = DEFAULT_SUMMARY_USER_CONFIRM_PROMPT; + $('summary-assistant-prefill-prompt').value = DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT; + }; + $('btn-reset-memory-prompt-template').onclick = () => { + $('memory-prompt-template').value = DEFAULT_MEMORY_PROMPT_TEMPLATE; + }; // Trigger timing $('trigger-timing').onchange = e => { @@ -1627,6 +2061,7 @@ }; // Vector UI + initSummaryIOUI(); initVectorUI(); // Gen params collapsible @@ -1777,4 +2212,5 @@ setHtml(container, html); } + })(); diff --git a/modules/story-summary/story-summary.css b/modules/story-summary/story-summary.css index a7d8b2b..bff5713 100644 --- a/modules/story-summary/story-summary.css +++ b/modules/story-summary/story-summary.css @@ -1506,6 +1506,7 @@ h1 span { margin-bottom: 4px; } + .vector-stats { display: flex; gap: 8px; diff --git a/modules/story-summary/story-summary.html b/modules/story-summary/story-summary.html index bef07e8..94ec320 100644 --- a/modules/story-summary/story-summary.html +++ b/modules/story-summary/story-summary.html @@ -161,8 +161,9 @@ + +
+
导出与导入
+
+ + +
+
复制会把记忆包放进剪贴板;导入会覆盖当前聊天的总结资料,并自动清空向量与总结边界。
+
@@ -561,6 +573,18 @@ style="flex:1">导入向量数据
+
+ + +
+
+
+ +
@@ -569,6 +593,75 @@ +
+
+
+
增量总结提示词
+ +
+
这里展示的是一次完整增量总结的各段提示词。像 {$nextEventId}{$existingEventCount} 这样的占位符会在运行时自动替换,不要删除。
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
记忆注入提示词
+ +
+
+
+ +
必须保留 {$剧情记忆} 这个占位符,运行时会替换成实际记忆内容。
+
+
+
+
+
@@ -847,6 +940,9 @@
+ diff --git a/modules/story-summary/story-summary.js b/modules/story-summary/story-summary.js index 40d1c01..be36c37 100644 --- a/modules/story-summary/story-summary.js +++ b/modules/story-summary/story-summary.js @@ -89,7 +89,7 @@ import { } from "./vector/storage/state-store.js"; // vector io -import { exportVectors, importVectors } from "./vector/storage/vector-io.js"; +import { exportVectors, importVectors, backupToServer, restoreFromServer, fetchManifest, deleteServerBackup, isDeleteUnsupportedError, getBackupFilename } from "./vector/storage/vector-io.js"; import { invalidateLexicalIndex, warmupIndex, addDocumentsForFloor, removeDocumentsByFloor, addEventDocuments } from "./vector/retrieval/lexical-index.js"; @@ -182,6 +182,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 向量提醒节流 let lastVectorWarningAt = 0; const VECTOR_WARNING_COOLDOWN_MS = 120000; // 2分钟内不重复提醒 +let backupDeleteSupported = true; +let backupDeleteUnsupportedReason = ''; const EXT_PROMPT_KEY = "LittleWhiteBox_StorySummary"; const MIN_INJECTION_DEPTH = 2; @@ -942,10 +944,8 @@ function initButtonsForAll() { async function sendSavedConfigToFrame() { try { - const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null); - if (savedConfig) { - postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig }); - } + const savedConfig = getSummaryPanelConfig(); + postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig }); } catch (e) { xbLog.warn(MODULE_ID, "加载面板配置失败", e); } @@ -1029,6 +1029,270 @@ function buildFramePayload(store) { }; } +async function copyTextToClipboard(text) { + const value = String(text ?? ""); + if (!value) { + throw new Error("没有可复制的内容"); + } + + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const ta = document.createElement("textarea"); + ta.value = value; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + ta.setSelectionRange(0, ta.value.length); + const ok = document.execCommand?.("copy"); + ta.remove(); + if (!ok) { + throw new Error("浏览器不支持自动复制"); + } +} + +function stripFloorMarker(summary) { + return String(summary || "") + .replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "") + .trim(); +} + +function normalizeInternalFact(item) { + const fact = item && typeof item === "object" ? item : {}; + const base = { + id: String(fact?.id || "").trim(), + s: String(fact?.s ?? "").trim(), + p: String(fact?.p ?? "").trim(), + o: String(fact?.o ?? "").trim(), + }; + + const stateValue = fact?._isState ?? fact?.isState; + if (stateValue != null) { + base._isState = !!stateValue; + } + + const trendValue = String(fact?.trend ?? "").trim(); + if (trendValue) { + base.trend = trendValue; + } + + return base; +} + +function normalizePortableFact(item) { + const fact = item && typeof item === "object" ? item : {}; + const base = { + id: String(fact?.id || "").trim(), + s: String(fact?.人物名字 ?? "").trim(), + p: String(fact?.种类 ?? "").trim(), + o: String(fact?.描述 ?? "").trim(), + }; + + const stateValue = fact?._isState ?? fact?.isState ?? fact?.核心事实; + if (stateValue != null) { + base._isState = !!stateValue; + } + + const trendValue = String(fact?.trend ?? fact?.趋势 ?? "").trim(); + if (trendValue) { + base.trend = trendValue; + } + + return base; +} + +function serializePortableFact(fact) { + const out = { + 人物名字: String(fact?.s || "").trim(), + 种类: String(fact?.p || "").trim(), + 描述: String(fact?.o || "").trim(), + }; + + if (fact?._isState != null) { + out.核心事实 = !!fact._isState; + } + + if (fact?.trend) { + out.趋势 = String(fact.trend).trim(); + } + + return out; +} + +function cloneSummaryJsonForPortability(json) { + const src = json && typeof json === "object" ? json : {}; + const characters = src.characters && typeof src.characters === "object" ? src.characters : {}; + return { + keywords: Array.isArray(src.keywords) + ? src.keywords.map((item) => ({ + text: String(item?.text || "").trim(), + weight: String(item?.weight || "").trim(), + })).filter((item) => item.text) + : [], + events: Array.isArray(src.events) + ? src.events.map((item) => ({ + id: String(item?.id || "").trim(), + title: String(item?.title || "").trim(), + timeLabel: String(item?.timeLabel || "").trim(), + summary: stripFloorMarker(item?.summary), + participants: Array.isArray(item?.participants) + ? item.participants.map((name) => String(name || "").trim()).filter(Boolean) + : [], + type: String(item?.type || "").trim(), + weight: String(item?.weight || "").trim(), + causedBy: Array.isArray(item?.causedBy) + ? item.causedBy.map((id) => String(id || "").trim()).filter(Boolean) + : [], + })).filter((item) => item.id || item.title || item.summary) + : [], + characters: { + main: Array.isArray(characters.main) + ? characters.main + .map((item) => typeof item === "string" + ? { name: String(item).trim() } + : { name: String(item?.name || "").trim() }) + .filter((item) => item.name) + : (Array.isArray(characters) + ? characters + .map((item) => typeof item === "string" + ? { name: String(item).trim() } + : { name: String(item?.name || "").trim() }) + .filter((item) => item.name) + : []), + }, + arcs: Array.isArray(src.arcs) + ? src.arcs.map((item) => ({ + name: String(item?.name || "").trim(), + trajectory: String(item?.trajectory || "").trim(), + progress: Number.isFinite(Number(item?.progress)) ? Number(item.progress) : 0, + moments: Array.isArray(item?.moments) + ? item.moments + .map((moment) => typeof moment === "string" + ? { text: String(moment).trim() } + : { text: String(moment?.text || "").trim() }) + .filter((moment) => moment.text) + : [], + })).filter((item) => item.name) + : [], + facts: Array.isArray(src.facts) + ? src.facts.map(normalizeInternalFact).filter((item) => item.s && item.p && item.o) + : [], + }; +} + +function extractSummaryImportJson(raw) { + if (!raw || typeof raw !== "object") { + throw new Error("文件内容不是有效 JSON 对象"); + } + + const candidate = + (raw.type === "LittleWhiteBoxStorySummaryMemory" && raw.data && typeof raw.data === "object" ? raw.data : null) || + (raw.storySummary?.json && typeof raw.storySummary.json === "object" ? raw.storySummary.json : null) || + (raw.json && typeof raw.json === "object" ? raw.json : null) || + raw; + + const hasSummaryShape = + Array.isArray(candidate.keywords) || + Array.isArray(candidate.events) || + Array.isArray(candidate.arcs) || + Array.isArray(candidate.facts) || + (candidate.characters && typeof candidate.characters === "object"); + + if (!hasSummaryShape) { + throw new Error("未识别到可导入的总结数据"); + } + + const json = cloneSummaryJsonForPortability(candidate); + json.facts = Array.isArray(candidate.facts) + ? candidate.facts.map(normalizePortableFact).filter((item) => item.s && item.p && item.o) + : []; + return json; +} + +function buildSummaryExportPackage(store) { + const json = cloneSummaryJsonForPortability(store?.json || {}); + const data = { + ...json, + facts: json.facts.map(serializePortableFact), + }; + return { + type: "LittleWhiteBoxStorySummaryMemory", + version: 1, + exportedAt: new Date().toISOString(), + data, + counts: { + keywords: json.keywords.length, + events: json.events.length, + characters: json.characters.main.length, + arcs: json.arcs.length, + facts: json.facts.length, + }, + }; +} + +async function importSummaryMemoryPackage(rawText) { + if (!String(rawText || "").trim()) { + throw new Error("记忆包内容为空"); + } + let parsed; + try { + parsed = JSON.parse(String(rawText)); + } catch { + throw new Error("JSON 解析失败"); + } + + const importedJson = extractSummaryImportJson(parsed); + const { chatId, chat } = getContext(); + if (!chatId) { + throw new Error("当前没有打开聊天"); + } + + await clearAllAtomsAndVectors(chatId); + await clearAllChunks(chatId); + await clearEventVectors(chatId); + await clearStateVectors(chatId); + await updateMeta(chatId, { lastChunkFloor: -1, fingerprint: null }); + + invalidateLexicalIndex(); + + const store = getSummaryStore(); + if (!store) { + throw new Error("无法读取当前聊天的总结存储"); + } + + store.json = importedJson; + store.lastSummarizedMesId = -1; + store.summaryHistory = []; + store.updatedAt = Date.now(); + saveSummaryStore(); + + _lastBuiltPromptText = ""; + + refreshEntityLexiconAndWarmup(); + scheduleLexicalWarmup(); + + await clearHideState(); + const totalFloors = Array.isArray(chat) ? chat.length : 0; + await sendFrameBaseData(store, totalFloors); + sendFrameFullData(store, totalFloors); + await sendAnchorStatsToFrame(); + await sendVectorStatsToFrame(); + + return { + counts: { + keywords: importedJson.keywords.length, + events: importedJson.events.length, + characters: importedJson.characters.main.length, + arcs: importedJson.arcs.length, + facts: importedJson.facts.length, + }, + }; +} + // Compatibility export for ena-planner. // Returns a compact plain-text snapshot of story-summary memory. export function getStorySummaryForEna() { @@ -1424,6 +1688,43 @@ async function handleFrameMessage(event) { })(); break; + case "SUMMARY_COPY": + (async () => { + try { + const store = getSummaryStore(); + const payload = buildSummaryExportPackage(store); + await copyTextToClipboard(JSON.stringify(payload, null, 2)); + postToFrame({ + type: "SUMMARY_COPY_RESULT", + success: true, + events: payload.counts.events, + facts: payload.counts.facts, + }); + } catch (e) { + postToFrame({ type: "SUMMARY_COPY_RESULT", success: false, error: e.message }); + } + })(); + break; + + case "SUMMARY_IMPORT_TEXT": + if (guard.isAnyRunning('summary', 'vector', 'anchor')) { + postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: "请等待当前总结/向量任务结束" }); + break; + } + (async () => { + try { + const result = await importSummaryMemoryPackage(data.text || ""); + postToFrame({ + type: "SUMMARY_IMPORT_RESULT", + success: true, + counts: result.counts, + }); + } catch (e) { + postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: e.message }); + } + })(); + break; + case "VECTOR_IMPORT_PICK": // 在 parent 创建 file picker,避免 iframe 传大文件 (async () => { @@ -1459,6 +1760,56 @@ async function handleFrameMessage(event) { input.click(); })(); break; + case "VECTOR_BACKUP_SERVER": + (async () => { + try { + const result = await backupToServer((status) => { + postToFrame({ type: "VECTOR_IO_STATUS", status }); + }); + postToFrame({ + type: "VECTOR_BACKUP_RESULT", + success: true, + size: result.size, + chunkCount: result.chunkCount, + eventCount: result.eventCount, + }); + } catch (e) { + postToFrame({ type: "VECTOR_BACKUP_RESULT", success: false, error: e.message }); + } + })(); + break; + + case "VECTOR_RESTORE_SERVER": + (async () => { + try { + const result = await restoreFromServer((status) => { + postToFrame({ type: "VECTOR_IO_STATUS", status }); + }); + postToFrame({ + type: "VECTOR_RESTORE_RESULT", + success: true, + chunkCount: result.chunkCount, + eventCount: result.eventCount, + warnings: result.warnings, + fingerprintMismatch: result.fingerprintMismatch, + }); + await sendVectorStatsToFrame(); + } catch (e) { + postToFrame({ type: "VECTOR_RESTORE_RESULT", success: false, error: e.message }); + } + })(); + break; + + case "VECTOR_LIST_BACKUPS": + (async () => { + try { + const files = await fetchManifest(); + showBackupManagerModal(files); + } catch (e) { + showBackupManagerModal([]); + } + })(); + break; case "REQUEST_VECTOR_STATS": sendVectorStatsToFrame(); @@ -1600,6 +1951,7 @@ async function handleManualGenerate(mesId, config) { async function handleChatChanged() { if (!events) return; + _lastBuiltPromptText = ""; // ← 加这一行,切聊天时清掉旧 summary const { chat } = getContext(); activeChatId = getContext().chatId || null; const newLength = Array.isArray(chat) ? chat.length : 0; @@ -1895,6 +2247,10 @@ function registerEvents() { events.on(event_types.GENERATION_STARTED, handleGenerationStarted); events.on(event_types.GENERATION_STOPPED, clearExtensionPrompt); events.on(event_types.GENERATION_ENDED, clearExtensionPrompt); + + // 聊天删除时清理对应的服务器向量备份 + events.on(event_types.CHAT_DELETED, handleChatDeleted); + events.on(event_types.GROUP_CHAT_DELETED, handleChatDeleted); } function unregisterEvents() { @@ -1915,6 +2271,169 @@ function unregisterEvents() { document.removeEventListener("keydown", onSendKeydown, true); } +// ═══════════════════════════════════════════════════════════════════════════ +// 聊天删除时自动清理服务器向量备份 +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleChatDeleted(chatId) { + try { + const filename = getBackupFilename(chatId); + await deleteServerBackup(filename, null); + xbLog.info(MODULE_ID, `聊天删除,已清理服务器备份: ${filename}`); + } catch (_) { + // 文件不存在或宿主不支持删除,静默处理 + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 备份管理 Modal(渲染在父窗口,确保层级在 settings modal 之上) +// ═══════════════════════════════════════════════════════════════════════════ + +function showBackupManagerModal(initialFiles) { + document.getElementById('lwb-backup-manager-modal')?.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'lwb-backup-manager-modal'; + overlay.style.cssText = [ + 'position:fixed', 'inset:0', 'background:rgba(0,0,0,.55)', + 'z-index:100000', 'display:flex', 'align-items:center', 'justify-content:center', + ].join(';'); + + const box = document.createElement('div'); + box.style.cssText = [ + 'background:#fff', 'color:#222', 'border-radius:8px', + 'width:min(520px,92vw)', 'padding:18px', + 'max-height:80vh', 'display:flex', 'flex-direction:column', + 'box-shadow:0 8px 32px rgba(0,0,0,.35)', 'font-size:14px', + ].join(';'); + + // Header + const header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px'; + const title = document.createElement('span'); + title.style.cssText = 'font-weight:700;font-size:15px'; + title.textContent = '服务器向量备份'; + const badge = document.createElement('span'); + badge.id = 'lwb-backup-badge'; + badge.style.cssText = 'opacity:0.5;font-size:0.85em;margin-left:4px'; + title.appendChild(badge); + + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:6px'; + + const btnRefresh = document.createElement('button'); + btnRefresh.className = 'btn btn-sm'; + btnRefresh.textContent = '刷新'; + + const btnClose = document.createElement('button'); + btnClose.className = 'btn btn-sm'; + btnClose.textContent = '✕'; + btnClose.onclick = () => overlay.remove(); + + btnRow.append(btnRefresh, btnClose); + header.append(title, btnRow); + + // List area + const listEl = document.createElement('div'); + listEl.id = 'lwb-backup-list'; + listEl.style.cssText = 'overflow-y:auto;flex:1;min-height:60px'; + + // Status bar + const statusEl = document.createElement('div'); + statusEl.id = 'lwb-backup-status'; + statusEl.style.cssText = 'margin-top:8px;font-size:0.82em;color:#666;min-height:1em'; + + box.append(header, listEl, statusEl); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Close on backdrop click + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + + function setStatus(text, isError) { + statusEl.textContent = text; + statusEl.style.color = isError ? '#c00' : '#666'; + } + + function renderList(files) { + badge.textContent = `(${files.length})`; + if (!files.length) { + listEl.innerHTML = '
暂无备份记录
'; + return; + } + const sorted = [...files].sort((a, b) => new Date(b.backupTime) - new Date(a.backupTime)); + listEl.replaceChildren(); + sorted.forEach(f => { + const row = document.createElement('div'); + row.style.cssText = [ + 'display:flex', 'gap:8px', 'align-items:center', 'padding:6px 2px', + 'border-bottom:1px solid #e8e8e8', 'font-size:0.82em', + ].join(';'); + + const label = document.createElement('span'); + label.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#333'; + label.title = f.chatId || f.filename; + label.textContent = f.chatId || f.filename; + + const size = document.createElement('span'); + size.style.cssText = 'white-space:nowrap;color:#555'; + size.textContent = f.size ? (f.size / 1024 / 1024).toFixed(2) + 'MB' : '?'; + + const time = document.createElement('span'); + time.style.cssText = 'white-space:nowrap;color:#888'; + time.textContent = f.backupTime ? new Date(f.backupTime).toLocaleString() : '?'; + + const btnDel = document.createElement('button'); + btnDel.className = 'btn btn-sm'; + btnDel.style.cssText = 'padding:1px 10px;flex-shrink:0;color:#c00;border-color:#c00'; + btnDel.textContent = '删'; + btnDel.onclick = async () => { + if (!confirm(`确认删除此备份?\n${f.filename}`)) return; + setStatus('删除中...'); + btnDel.disabled = true; + try { + await deleteServerBackup(f.filename, f.serverPath); + setStatus('已删除'); + const updated = await fetchManifest(); + renderList(updated); + } catch (e) { + if (isDeleteUnsupportedError(e)) { + backupDeleteSupported = false; + backupDeleteUnsupportedReason = e.message || '宿主不支持删除接口'; + setStatus('⚠️ 只读模式:' + backupDeleteUnsupportedReason, true); + // 禁用所有删除按钮 + listEl.querySelectorAll('button').forEach(b => { b.disabled = true; }); + } else { + setStatus('删除失败: ' + (e.message || '未知'), true); + btnDel.disabled = false; + } + } + }; + + row.append(label, size, time, btnDel); + listEl.appendChild(row); + }); + + if (!backupDeleteSupported) { + setStatus('⚠️ 只读模式:' + backupDeleteUnsupportedReason, true); + listEl.querySelectorAll('button').forEach(b => { b.disabled = true; }); + } + } + + btnRefresh.onclick = async () => { + setStatus('加载中...'); + try { + const files = await fetchManifest(); + renderList(files); + setStatus(''); + } catch (e) { + setStatus('加载失败: ' + e.message, true); + } + }; + + renderList(initialFiles); +} + // ═══════════════════════════════════════════════════════════════════════════ // Toggle 监听 // ═══════════════════════════════════════════════════════════════════════════