// ==UserScript==
// @name ClaudePowerestManager&Enhancer
// @name:zh-CN Claude神级拓展增强脚本
// @namespace http://tampermonkey.net/
// @version 1.2.1
// @description 一站式搜索、筛选、批量管理所有对话。强大的JSON导出(原始/自定义/含附件)。为聊天框注入新功能,如从任意消息分支、跨分支全局导航、强制PDF深度解析、浮动线性导航面板等。
// @description:zh-CN [管理器] 右下角打开管理器面板开启一站式搜索、筛选、批量管理所有对话。强大的JSON导出(原始/自定义/含附件)。[增强器]为聊天框注入新功能,如从任意消息分支、跨分支全局导航、强制PDF深度解析、浮动线性导航面板等。
// @description:en [Manager] Opens a management panel in the bottom-right corner for one-stop searching, filtering, and batch management of all conversations. Powerful JSON export (raw/custom/with attachments). [Enhancer] Injects new features into the chat interface, such as branching from any message, cross-branch navigation, forced deep PDF parsing, floating linear navigation panel, and more.
// @author f14xuanlv
// @license MIT
// @homepageURL https://github.com/f14XuanLv/Claude-Powerest-Manager_Enhancer
// @supportURL https://github.com/f14XuanLv/Claude-Powerest-Manager_Enhancer/issues
// @match https://claude.ai/*
// @include /^https:\/\/.*\.fuclaude\.[a-z]{3}\/.*$/
// @icon https://www.google.com/s2/favicons?sz=64&domain=claude.ai
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// ==/UserScript==
(function(window) {
'use strict';
const LOG_PREFIX = "[ClaudePowerestManager&Enhancer v1.2.1]:"
console.log(LOG_PREFIX, "脚本已加载。");
// 全局HTML转义函数 - 统一的转义实现
function escapeHTML(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, function(match) {
return {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[match];
});
}
// 全局字符串分割工具函数 - 避免原型污染
function rsplit(str, sep, maxsplit) {
const split = str.split(sep);
return maxsplit ? [split.slice(0, -maxsplit).join(sep)].concat(split.slice(-maxsplit)) : split;
}
// =========================================================================
// 0. 全局配置
// =========================================================================
const Config = {
INITIAL_PARENT_UUID: "00000000-0000-4000-8000-000000000000",
TOOLBAR_SELECTOR: 'div.relative.flex-1.flex.items-center.gap-2.shrink.min-w-0',
EMPTY_AREA_SELECTOR: 'div.flex.flex-row.items-center.gap-2.min-w-0',
FORCE_UPLOAD_TARGET_EXTENSIONS: [".pdf"],
ATTACHMENT_PANEL_ID: 'cpm-attachment-preview-panel',
EXPORT_MODAL_ID: 'cpm-export-modal',
URL_GITHUB_REPO: 'https://github.com/f14XuanLv/Claude-Powerest-Manager_Enhancer',
URL_STUDIO_REPO: 'https://github.com/f14XuanLv/claude-dialog-tree-studio',
maxPreviewLength: 16,
refreshInterval: 150,
topMargin: 200,
STORAGE_KEY: 'cpm-ln-panel-state'
};
// =========================================================================
// 1. 设置模块注册(不可用)表
// =========================================================================
/**
* @typedef {object} ISettingModule - 设置模块接口定义
* @property {string} id - 模块的唯一ID。
* @property {string} title - 在设置面板中显示的标题。
* @property {function(): string} render - 返回该模块设置的HTML字符串。
* @property {function(HTMLElement): void} load - 从GM存储中加载设置并更新UI。
* @property {function(HTMLElement): void} save - 从UI读取设置并保存到GM存储。
* @property {function(HTMLElement): void} [addEventListeners] - (可选) 为模块的UI元素添加特定的事件监听器。
*/
const SettingsRegistry = {
/** @type {ISettingModule[]} */
modules: [],
/** @param {ISettingModule} module */
register(module) {
if (this.modules.find(m => m.id === module.id)) {
console.warn(LOG_PREFIX, `尝试重复注册(不可用)设置模块: ${module.id}`);
return;
}
this.modules.push(module);
console.log(LOG_PREFIX, `设置模块已注册(不可用): ${module.id}`);
}
};
// =========================================================================
// 2. 各功能模块定义
// =========================================================================
// --- 2.1 主题设置模块 ---
const ThemeSettingsModule = {
id: 'theme',
title: '外观设置',
render() {
return `
<div class="cpm-setting-group">
<div class="cpm-setting-item">
<label for="cpm-theme-mode" class="cpm-settings-label">脚本主题:</label>
<select id="cpm-theme-mode">
<option value="auto">跟随网站</option>
<option value="light">锁定白天</option>
<option value="dark">锁定黑夜</option>
</select>
</div>
</div>
`;
},
load(container) {
const themeSelect = container.querySelector('#cpm-theme-mode');
if (themeSelect) themeSelect.value = GM_getValue('themeMode', 'auto');
},
save(container) {
const themeSelect = container.querySelector('#cpm-theme-mode');
if (themeSelect) {
GM_setValue('themeMode', themeSelect.value);
ThemeManager.applyCurrentTheme();
}
}
};
// --- 2.2 批量操作设置模块 ---
const BatchOpsSettingsModule = {
id: 'batchOps',
title: '批量操作设置',
render() {
return `
<div class="cpm-setting-group">
<h4>批量收藏/取消收藏</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-refresh-after-star"><label for="cpm-refresh-after-star">操作后从服务器刷新列表 (否则仅更新当前视图)</label></div>
</div>
<div class="cpm-setting-group">
<h4>批量删除</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-refresh-after-delete"><label for="cpm-refresh-after-delete">操作后从服务器刷新列表 (否则仅更新当前视图)</label></div>
</div>
<div class="cpm-setting-group">
<h4>批量自动重命名</h4>
<div class="cpm-setting-item"><label for="cpm-rename-lang" class="cpm-settings-label">标题语言:</label><input type="text" id="cpm-rename-lang" placeholder="例如:中文, English, 日本語"></div>
<div class="cpm-setting-item"><label for="cpm-rename-rounds" class="cpm-settings-label">使用对话轮数 (最多):</label><input type="number" id="cpm-rename-rounds" min="1" max="10" step="1"></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-refresh-after-rename"><label for="cpm-refresh-after-rename">操作后从服务器刷新列表 (否则仅更新当前视图)</label></div>
</div>
`;
},
load(container) {
container.querySelector('#cpm-rename-lang').value = GM_getValue('renameLang', '中文');
container.querySelector('#cpm-rename-rounds').value = GM_getValue('renameRounds', '2');
container.querySelector('#cpm-refresh-after-rename').checked = GM_getValue('refreshAfterRename', false);
container.querySelector('#cpm-refresh-after-star').checked = GM_getValue('refreshAfterStar', false);
container.querySelector('#cpm-refresh-after-delete').checked = GM_getValue('refreshAfterDelete', false);
},
save(container) {
GM_setValue('renameLang', container.querySelector('#cpm-rename-lang').value);
GM_setValue('renameRounds', container.querySelector('#cpm-rename-rounds').value);
GM_setValue('refreshAfterRename', container.querySelector('#cpm-refresh-after-rename').checked);
GM_setValue('refreshAfterStar', container.querySelector('#cpm-refresh-after-star').checked);
GM_setValue('refreshAfterDelete', container.querySelector('#cpm-refresh-after-delete').checked);
}
};
// --- 2.3 导出设置模块 ---
const ExportSettingsModule = {
id: 'export',
title: '自定义导出默认设置',
render() {
return ManagerUI.createExportSettingsHTML(true);
},
load(container) {
ManagerUI.loadExportSettings(container);
},
save(container) {
ManagerUI.saveExportSettings(container);
},
addEventListeners(container) {
ManagerUI.setupSubOptionDisabling(container);
}
};
// --- 2.4 注册(不可用)所有设置模块 ---
SettingsRegistry.register(ThemeSettingsModule);
SettingsRegistry.register(BatchOpsSettingsModule);
SettingsRegistry.register(ExportSettingsModule);
// =========================================================================
// 3. 主题管理器 (共享)
// =========================================================================
const ThemeManager = {
init() {
this.applyCurrentTheme();
this.observer = new MutationObserver(() => this.applyCurrentTheme());
this.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-mode'] });
console.log(LOG_PREFIX, "主题管理器已初始化并开始监听。");
},
cleanup() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
},
applyCurrentTheme() {
const mode = GM_getValue('themeMode', 'auto');
let theme;
if (mode === 'light' || mode === 'dark') {
theme = mode;
} else {
theme = document.documentElement.getAttribute('data-mode') || 'light';
}
document.body.setAttribute('cpm-theme', theme);
},
};
// =========================================================================
// 4. 存储管理器和文本处理工具
// =========================================================================
const StorageManager = {
getPanelState() {
try {
const state = localStorage.getItem(Config.STORAGE_KEY);
return state === 'true';
} catch (e) {
return false;
}
},
setPanelState(isOpen) {
try {
localStorage.setItem(Config.STORAGE_KEY, String(isOpen));
} catch (e) {
// 忽略存储错误
}
}
};
const TextUtils = {
getPreview(element, maxLength = Config.maxPreviewLength) {
if (!element) return '';
const text = (element.innerText || element.textContent || '')
.replace(/\s+/g, ' ').trim();
if (!text) return '';
let width = 0, result = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
const charWidth = /[\u4e00-\u9fa5]/.test(char) ? 2 : 1;
if (width + charWidth > maxLength) {
result += '…';
break;
}
result += char;
width += charWidth;
}
return result || text.slice(0, maxLength);
}
};
// =========================================================================
// 5. API 层 (共享)
// =========================================================================
const ClaudeAPI = {
orgUuid: null,
orgInfo: null,
conversationTree: null,
currentLinearBranch: null,
currentConversationUuid: null,
isInitialized: false,
async getOrganizationInfo() {
if (this.orgInfo) return this.orgInfo;
try {
const response = await fetch('/api/organizations');
if (!response.ok) throw new Error(`组织API请求失败: ${response.status}`);
const orgs = await response.json();
if (orgs && orgs.length > 0) {
this.orgInfo = orgs[0];
this.orgUuid = this.orgInfo.uuid;
return this.orgInfo;
}
throw new Error("在API响应中未找到组织信息。");
} catch (error) {
console.error(LOG_PREFIX, "获取组织信息失败:", error);
throw error;
}
},
async getOrgUuid() {
if (this.orgUuid) return this.orgUuid;
const info = await this.getOrganizationInfo();
return info.uuid;
},
async getConversations() {
const orgId = await this.getOrgUuid();
const response = await fetch(`/api/organizations/${orgId}/chat_conversations`);
if (!response.ok) throw new Error(`获取会话列表失败: ${response.status}`);
return response.json();
},
async getConversationHistory(convUuid) {
const orgId = await this.getOrgUuid();
const url = `/api/organizations/${orgId}/chat_conversations/${convUuid}?tree=True&rendering_mode=messages&render_all_tools=true`;
const response = await fetch(url);
if (!response.ok) throw new Error(`获取历史记录失败: ${response.status}`);
const data = await response.json();
// 标记脏数据:标记没有 Claude 回复的孤儿用户节点
data.chat_messages = this.markDirtyMessages(data.chat_messages);
this.conversationTree = this.buildConversationTree(data.chat_messages);
this.updateCurrentLinearBranch();
return data;
},
markDirtyMessages(messages) {
// 按 index 排序消息以确保正确的时间顺序
const sortedMessages = [...messages].sort((a, b) => a.index - b.index);
let dirtyCount = 0;
console.log(`${LOG_PREFIX} 开始标记脏数据,原始消息数量: ${messages.length}`);
for (let i = 0; i < sortedMessages.length; i++) {
const currentMessage = sortedMessages[i];
// 如果是用户消息,检查下一个消息是否是 Claude 的回复
if (currentMessage.sender === 'human') {
const nextMessage = sortedMessages[i + 1];
// 如果没有下一个消息,或者下一个消息也是用户消息,说明是孤儿用户节点
if (!nextMessage || nextMessage.sender === 'human') {
console.log(`${LOG_PREFIX} 发现孤儿用户节点: ${currentMessage.uuid.slice(-8)}, index: ${currentMessage.index}, 内容: "${currentMessage.content?.[0]?.text?.slice(0, 50) || '空内容'}..."`);
// 标记为脏数据而不是删除
currentMessage._isDirtyData = true;
dirtyCount++;
}
}
}
if (dirtyCount > 0) {
console.log(`${LOG_PREFIX} 标记完成,标记了 ${dirtyCount} 个孤儿用户节点为脏数据,总消息数量: ${messages.length}`);
}
return messages; // 返回包含脏数据标记的完整消息列表
},
buildConversationTree(messages) {
const nodes = {};
messages.forEach(msg => { nodes[msg.uuid] = msg; });
const childrenMap = {};
messages.forEach(msg => {
const parentUuid = msg.parent_message_uuid || Config.INITIAL_PARENT_UUID;
if (!childrenMap[parentUuid]) childrenMap[parentUuid] = [];
childrenMap[parentUuid].push(msg.uuid);
});
for (const parentUuid in childrenMap) {
childrenMap[parentUuid].sort((a, b) => new Date(nodes[a].created_at) - new Date(nodes[b].created_at));
}
function assignIdsRecursive(nodeUuid, prefix) {
if (!nodes[nodeUuid]) return;
const node = nodes[nodeUuid];
node.tree_id = prefix;
const children = childrenMap[nodeUuid] || [];
let normalIndex = 0;
let dirtyCount = 1;
children.forEach((childUuid) => {
const childNode = nodes[childUuid];
if (!childNode) return;
// 检测脏数据:标记了 _isDirtyData 的节点
const isDirtyData = childNode._isDirtyData;
if (isDirtyData) {
assignIdsRecursive(childUuid, `${prefix}-F${dirtyCount}`);
dirtyCount++;
} else {
assignIdsRecursive(childUuid, `${prefix}-${normalIndex}`);
normalIndex++;
}
});
}
const rootNodes = childrenMap[Config.INITIAL_PARENT_UUID] || [];
rootNodes.forEach((rootUuid, index) => {
assignIdsRecursive(rootUuid, `root-${index}`);
});
return { nodes, childrenMap, rootNodes };
},
async createTempConversation() {
const orgId = await this.getOrgUuid();
const tempConvUuid = crypto.randomUUID();
await fetch(`/api/organizations/${orgId}/chat_conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: tempConvUuid, name: "" })
});
return tempConvUuid;
},
async deleteConversations(convUuids) {
const orgId = await this.getOrgUuid();
const isSingle = convUuids.length === 1;
const url = isSingle ? `/api/organizations/${orgId}/chat_conversations/${convUuids[0]}` : `/api/organizations/${orgId}/chat_conversations/delete_many`;
const options = { method: isSingle ? 'DELETE' : 'POST', headers: { 'Content-Type': 'application/json' } };
if (!isSingle) options.body = JSON.stringify({ conversation_uuids: convUuids });
const response = await fetch(url, options);
if (!response.ok) throw new Error(`删除API请求失败: ${response.statusText}`);
},
async generateTitle(tempConvUuid, messageContent) {
const orgId = await this.getOrgUuid();
const url = `/api/organizations/${orgId}/chat_conversations/${tempConvUuid}/title`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_content: messageContent, recent_titles: [] })
});
if (!response.ok) throw new Error("标题生成API请求失败。");
const { title } = await response.json();
if (!title || title.toLowerCase().includes('untitled')) throw new Error('生成了无效标题。');
return title;
},
async updateConversation(convUuid, payload) {
const orgId = await this.getOrgUuid();
const url = `/api/organizations/${orgId}/chat_conversations/${convUuid}`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`更新会话失败: ${response.statusText}`);
},
async downloadFile(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`文件下载失败: ${response.status} at ${url}`);
return response.blob();
},
async updateCurrentLinearBranch() {
// 分析当前前端显示的线性分支
if (!this.conversationTree) {
// 尝试自动初始化对话树
await this.tryInitializeConversationTree();
if (!this.conversationTree) {
this.currentLinearBranch = [];
return;
}
}
const turns = this.findCurrentTurns();
// 构建线性分支:前端DOM显示的必定是完整的父子串行关系
const branch = this.buildLinearBranchFromDOM(turns);
this.currentLinearBranch = branch;
return turns; // 返回turns避免重复调用
},
async tryInitializeConversationTree() {
// 智能初始化对话树 - 避免重复请求
try {
const currentUrl = window.location.href;
const pathParts = new URL(currentUrl).pathname.split('/');
const conversationUuid = (pathParts[1] === 'chat' && pathParts[2]) ? pathParts[2] : null;
if (!conversationUuid || conversationUuid === 'new') {
return false;
}
// 检查是否已经为当前对话初始化过
if (this.isInitialized && this.currentConversationUuid === conversationUuid) {
return true;
}
// 初始化新的对话树
await this.getConversationHistory(conversationUuid);
this.currentConversationUuid = conversationUuid;
this.isInitialized = true;
return true;
} catch (error) {
console.warn(`对话树初始化失败:`, error);
this.isInitialized = false;
}
return false;
},
// 检测对话切换,重置初始化状态
checkConversationChange() {
const currentUrl = window.location.href;
const pathParts = new URL(currentUrl).pathname.split('/');
const conversationUuid = (pathParts[1] === 'chat' && pathParts[2]) ? pathParts[2] : null;
if (conversationUuid !== this.currentConversationUuid) {
console.log(`检测到对话切换: ${this.currentConversationUuid?.slice(-8) || 'none'} → ${conversationUuid?.slice(-8) || 'none'}`);
this.isInitialized = false;
this.conversationTree = null;
this.currentLinearBranch = null;
this.currentConversationUuid = conversationUuid;
}
},
buildLinearBranchFromDOM(turns) {
// 基于DOM的串行父子关系构建分支
const branch = [];
let expectedParentUuid = Config.INITIAL_PARENT_UUID; // 根节点UUID(虚拟,不在前端显示)
// 预先提取所有兄弟信息,避免重复调用
const turnsWithSiblingInfo = turns.map(turn => ({
turn,
siblingInfo: this.extractSiblingInfo(turn)
}));
// 由于根节点不在前端显示,第一个DOM回合对应根节点的直接子节点
for (let i = 0; i < turnsWithSiblingInfo.length; i++) {
const { turn, siblingInfo } = turnsWithSiblingInfo[i];
const nodeUuid = this.findNodeByPositionWithCachedInfo(turn, expectedParentUuid, siblingInfo);
if (nodeUuid) {
const node = { ...this.conversationTree.nodes[nodeUuid], uuid: nodeUuid };
// 检查是否为脏数据,如果是则跳过(正常情况下不应该发生,因为脏数据不应该出现在DOM中)
if (!node._isDirtyData) {
branch.push(node);
expectedParentUuid = nodeUuid; // 下一个节点的父节点就是当前节点
} else {
console.warn(`DOM回合 ${i + 1} 匹配到脏数据节点,跳过: ${nodeUuid.slice(-8)}`);
}
} else {
console.warn(`DOM回合 ${i + 1} 无法匹配节点 (期望父: ${expectedParentUuid.slice(-8)})`);
break; // 中断构建,因为父子关系链断裂
}
}
return branch;
},
findNodeByPosition(turnElement, expectedParentUuid) {
// 基于位置信息在对话树中找到精确的节点
const siblingInfo = this.extractSiblingInfo(turnElement);
return this.findNodeByPositionWithCachedInfo(turnElement, expectedParentUuid, siblingInfo);
},
findNodeByPositionWithCachedInfo(turnElement, expectedParentUuid, siblingInfo) {
// 基于位置信息和缓存的兄弟信息在对话树中找到精确的节点
const isUser = !!turnElement.querySelector('[data-testid="user-message"]');
const expectedSender = isUser ? 'human' : 'assistant';
const { nodes, childrenMap } = this.conversationTree;
// 获取期望父节点的所有子节点,排除脏数据
const siblings = childrenMap[expectedParentUuid] || [];
const sameTypeSiblings = siblings.filter(uuid =>
nodes[uuid] && nodes[uuid].sender === expectedSender && !nodes[uuid]._isDirtyData
);
if (siblingInfo) {
// 有兄弟信息时,使用精确位置匹配
if (sameTypeSiblings.length === siblingInfo.totalSiblings) {
const targetIndex = siblingInfo.currentIndex;
if (targetIndex >= 0 && targetIndex < sameTypeSiblings.length) {
return sameTypeSiblings[targetIndex];
}
} else {
console.warn(`兄弟数量不匹配: DOM显示${siblingInfo.totalSiblings}个, 树中有${sameTypeSiblings.length}个`);
}
} else {
// 没有兄弟信息时的处理逻辑
if (sameTypeSiblings.length === 1) {
return sameTypeSiblings[0];
} else if (sameTypeSiblings.length > 1) {
// 选择时间最早的节点(通常是主分支)
const sortedSiblings = sameTypeSiblings.sort((a, b) =>
new Date(nodes[a].created_at) - new Date(nodes[b].created_at)
);
return sortedSiblings[0];
}
}
return null;
},
findCurrentTurns() {
// 基于实际DOM结构查找对话回合 - 只使用最精确的选择器
const elements = document.querySelectorAll('div[data-test-render-count]');
const validElements = Array.from(elements).filter(el => {
// 检查是否包含用户消息或Claude响应内容
const hasUserMessage = !!el.querySelector('[data-testid="user-message"]');
const hasClaudeResponse = !!el.querySelector('.font-claude-response');
// 必须是用户消息或Claude响应之一
return hasUserMessage || hasClaudeResponse;
});
return validElements;
},
extractSiblingInfo(turnElement) {
// 查找关键定位元素:<span class="self-center shrink-0 select-none font-small text-text-300">a / b</span>
// 其中 b 代表包括自己在内总共有多少个兄弟节点,a 代表自己处于兄弟节点的第几个(1基索引)
// 精确的类名匹配
let siblingSpan = turnElement.querySelector('span.self-center.shrink-0.select-none.font-small.text-text-300');
if (siblingSpan) {
const text = siblingSpan.textContent?.trim();
const match = text.match(/(\d+)\s*\/\s*(\d+)/);
if (match) {
const currentPosition = parseInt(match[1]); // 1基索引位置
const totalSiblings = parseInt(match[2]); // 包括自己在内的总数
return {
currentIndex: currentPosition - 1, // 转换为0基索引用于数组操作
totalSiblings: totalSiblings
};
}
}
return null;
},
extractNodeText(node) {
if (node.content && Array.isArray(node.content)) {
// 根据真实数据格式提取文本
for (const contentBlock of node.content) {
if (contentBlock.type === 'text' && contentBlock.text) {
return contentBlock.text;
}
}
}
return node.text || '';
},
// 检查目标节点是否在当前线性分支中
isNodeInCurrentBranch(nodeUuid) {
if (!this.currentLinearBranch) return false;
return this.currentLinearBranch.some(node => node && node.uuid === nodeUuid);
}
};
// =========================================================================
// 5. 分支切换核心功能
// =========================================================================
const BranchSwitcher = {
// 基础工具函数
wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
// 切换到阶段性目标节点
async switchToTargetStageNode(targetNodeUuid) {
console.log(`${LOG_PREFIX} 尝试切换到阶段性目标节点: ${targetNodeUuid.slice(-8)}`);
// 1. 判断阶段性目标节点是否在前端
if (ClaudeAPI.isNodeInCurrentBranch(targetNodeUuid)) {
console.log(`${LOG_PREFIX} 目标节点已在当前前端显示`);
return true;
}
// 2. 判断阶段性目标节点的父节点是否在前端
const { nodes } = ClaudeAPI.conversationTree;
const targetNode = nodes[targetNodeUuid];
if (!targetNode) {
console.error(`${LOG_PREFIX} 找不到目标节点: ${targetNodeUuid.slice(-8)}`);
return false;
}
// 检查目标节点是否为脏数据
if (targetNode._isDirtyData) {
console.error(`${LOG_PREFIX} 目标节点是脏数据,无法切换: ${targetNodeUuid.slice(-8)}`);
return false;
}
const parentUuid = targetNode.parent_message_uuid || Config.INITIAL_PARENT_UUID;
// 如果父节点是根节点,检查根节点是否等效在前端(即第一个节点的父节点)
let isParentInFrontend = false;
if (parentUuid === Config.INITIAL_PARENT_UUID) {
// 根节点场景:只要当前分支有节点,根节点就等效在前端
isParentInFrontend = ClaudeAPI.currentLinearBranch && ClaudeAPI.currentLinearBranch.length > 0;
} else {
isParentInFrontend = ClaudeAPI.isNodeInCurrentBranch(parentUuid);
}
if (!isParentInFrontend) {
console.log(`${LOG_PREFIX} 目标节点的父节点不在前端显示: ${parentUuid.slice(-8)}`);
return false;
}
// 3. 计算要执行的操作
const { childrenMap } = ClaudeAPI.conversationTree;
const siblings = childrenMap[parentUuid] || [];
const sameTypeSiblings = siblings.filter(uuid =>
nodes[uuid] && nodes[uuid].sender === targetNode.sender && !nodes[uuid]._isDirtyData
);
// 3.1 计算阶段性目标节点在其父节点的子节点中的位置index
const targetIndex = sameTypeSiblings.indexOf(targetNodeUuid);
if (targetIndex === -1) {
console.error(`${LOG_PREFIX} 在兄弟节点中找不到目标节点`);
return false;
}
// 3.2 计算当前前端显示的兄弟节点位置
let currentIndex = -1;
if (parentUuid === Config.INITIAL_PARENT_UUID) {
// 根节点场景:查找第一个同类型节点
if (ClaudeAPI.currentLinearBranch && ClaudeAPI.currentLinearBranch.length > 0) {
const firstNodeOfSameType = ClaudeAPI.currentLinearBranch.find(node =>
node && node.sender === targetNode.sender
);
if (firstNodeOfSameType) {
currentIndex = sameTypeSiblings.indexOf(firstNodeOfSameType.uuid);
}
}
} else {
// 非根节点场景:查找父节点后的第一个同类型节点
const parentIndexInBranch = ClaudeAPI.currentLinearBranch.findIndex(node =>
node && node.uuid === parentUuid
);
if (parentIndexInBranch !== -1) {
for (let i = parentIndexInBranch + 1; i < ClaudeAPI.currentLinearBranch.length; i++) {
const node = ClaudeAPI.currentLinearBranch[i];
if (node && node.sender === targetNode.sender) {
currentIndex = sameTypeSiblings.indexOf(node.uuid);
break;
}
}
}
}
if (currentIndex === -1) {
console.error(`${LOG_PREFIX} 无法确定当前同类型兄弟节点的位置`);
return false;
}
// 3.3 计算位置差
const diff = targetIndex - currentIndex;
console.log(`${LOG_PREFIX} 需要切换 ${diff} 步 (目标位置: ${targetIndex}, 当前位置: ${currentIndex})`);
if (diff === 0) {
console.log(`${LOG_PREFIX} 已经在目标位置`);
return true;
}
// 4. 执行切换操作
const direction = diff > 0 ? 'right' : 'left';
const steps = Math.abs(diff);
// 找到要操作的前端节点索引
let frontendNodeIndex = -1;
if (parentUuid === Config.INITIAL_PARENT_UUID) {
// 根节点场景:找到第一个同类型节点
for (let i = 0; i < ClaudeAPI.currentLinearBranch.length; i++) {
const node = ClaudeAPI.currentLinearBranch[i];
if (node && node.sender === targetNode.sender) {
frontendNodeIndex = i + 1; // 转换为1基索引
break;
}
}
} else {
// 非根节点场景:找到父节点后的第一个同类型节点
const parentIndexInBranch = ClaudeAPI.currentLinearBranch.findIndex(node =>
node && node.uuid === parentUuid
);
if (parentIndexInBranch !== -1) {
for (let i = parentIndexInBranch + 1; i < ClaudeAPI.currentLinearBranch.length; i++) {
const node = ClaudeAPI.currentLinearBranch[i];
if (node && node.sender === targetNode.sender) {
frontendNodeIndex = i + 1; // 转换为1基索引
break;
}
}
}
}
if (frontendNodeIndex === -1) {
console.error(`${LOG_PREFIX} 无法确定要操作的前端节点索引`);
return false;
}
// 执行切换步骤
for (let step = 0; step < steps; step++) {
console.log(`${LOG_PREFIX} 执行第 ${step + 1}/${steps} 步 ${direction} 切换`);
const success = await this.clickNodeSwitch(direction, frontendNodeIndex);
if (!success) {
console.error(`${LOG_PREFIX} 第 ${step + 1} 步切换失败`);
return false;
}
// 等待切换完成
await this.wait(300);
// 更新当前分支状态
await ClaudeAPI.updateCurrentLinearBranch();
}
// 5. 验证是否切换成功
await this.wait(200);
await ClaudeAPI.updateCurrentLinearBranch();
const success = ClaudeAPI.isNodeInCurrentBranch(targetNodeUuid);
console.log(`${LOG_PREFIX} 切换${success ? '成功' : '失败'}: ${targetNodeUuid.slice(-8)}`);
return success;
},
// 递归切换到目标节点
async switchToTargetNode(targetNodeUuid) {
console.log(`${LOG_PREFIX} 开始切换到目标节点: ${targetNodeUuid.slice(-8)}`);
// 确保对话树已初始化
if (!ClaudeAPI.conversationTree) {
await ClaudeAPI.tryInitializeConversationTree();
if (!ClaudeAPI.conversationTree) {
console.error(`${LOG_PREFIX} 对话树未初始化`);
return false;
}
}
// 更新当前分支状态
await ClaudeAPI.updateCurrentLinearBranch();
// 尝试直接切换到目标节点
if (await this.switchToTargetStageNode(targetNodeUuid)) {
return true;
}
// 如果直接切换失败,递归切换到父节点
const { nodes } = ClaudeAPI.conversationTree;
const targetNode = nodes[targetNodeUuid];
if (!targetNode) {
console.error(`${LOG_PREFIX} 找不到目标节点: ${targetNodeUuid.slice(-8)}`);
return false;
}
// 检查目标节点是否为脏数据
if (targetNode._isDirtyData) {
console.error(`${LOG_PREFIX} 目标节点是脏数据,无法递归切换: ${targetNodeUuid.slice(-8)}`);
return false;
}
const parentUuid = targetNode.parent_message_uuid;
if (!parentUuid || parentUuid === Config.INITIAL_PARENT_UUID) {
console.error(`${LOG_PREFIX} 已到达根节点,无法继续递归`);
return false;
}
console.log(`${LOG_PREFIX} 递归切换到父节点: ${parentUuid.slice(-8)}`);
// 递归调用切换到父节点
if (await this.switchToTargetNode(parentUuid)) {
// 父节点切换成功后,再次尝试切换到目标节点
console.log(`${LOG_PREFIX} 父节点切换成功,重新尝试切换到目标节点`);
return await this.switchToTargetStageNode(targetNodeUuid);
} else {
console.error(`${LOG_PREFIX} 递归失败,无法切换到父节点: ${parentUuid.slice(-8)}`);
return false;
}
},
// 通用切换函数(简化版,用于与现有按钮交互)
async clickNodeSwitch(direction, frontendIndex = 1) {
const turns = ClaudeAPI.findCurrentTurns();
if (frontendIndex < 1 || frontendIndex > turns.length) {
console.error(`${LOG_PREFIX} 前端节点索引超出范围。有效范围: 1-${turns.length}`);
return false;
}
// 在指定的前端节点中查找切换按钮
const targetTurn = turns[frontendIndex - 1];
let buttonSelector;
if (direction === 'right') {
buttonSelector = 'button[type="button"]:not([disabled]) svg path[d*="M6.13378 3.16011"]';
} else if (direction === 'left') {
buttonSelector = 'button[type="button"] svg path[d*="M13.2402 3.07224"]';
} else {
console.error(`${LOG_PREFIX} 无效的方向参数。请使用 'left' 或 'right'`);
return false;
}
const buttonPath = targetTurn.querySelector(buttonSelector);
if (buttonPath) {
const button = buttonPath.closest('button');
if (button && !button.disabled) {
console.log(`${LOG_PREFIX} 点击前端节点#${frontendIndex}的${direction === 'right' ? '右' : '左'}切换按钮`);
button.click();
await new Promise(resolve => setTimeout(resolve, 200));
return true;
}
}
console.error(`${LOG_PREFIX} 前端节点#${frontendIndex}没有可用的${direction === 'right' ? '右' : '左'}切换按钮`);
return false;
}
};
// =========================================================================
// 6. 线性跳转功能
// =========================================================================
const LinearNavigator = {
// 滚动到元素
scrollToElement(element, topMargin = Config.topMargin) {
if (!element) return;
const anchor = this.findAnchor(element);
const scroller = this.getScrollContainer(anchor);
if (!scroller) return;
const isWindow = scroller === document.documentElement ||
scroller === document.body ||
scroller === document.scrollingElement;
const scrollerRect = isWindow ?
{ top: 0, height: window.innerHeight } :
scroller.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
const currentScrollTop = isWindow ? window.scrollY : scroller.scrollTop;
const targetScrollTop = currentScrollTop + (anchorRect.top - scrollerRect.top) - topMargin;
const maxScrollTop = scroller.scrollHeight - scroller.clientHeight;
const finalScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
scroller.scrollTo({ top: finalScrollTop, behavior: 'smooth' });
// 高亮效果
this.addHighlight(element);
if (anchor !== element) this.addHighlight(anchor);
},
findAnchor(turnElement) {
const selectors = [
'[data-testid="user-message"]',
'.font-claude-response',
'p', 'li', 'pre'
];
for (const selector of selectors) {
const element = turnElement.querySelector(selector);
if (element && element.offsetParent) return element;
}
return turnElement;
},
getScrollContainer(element) {
let el = element;
while (el && el !== document.documentElement) {
const style = getComputedStyle(el);
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight) {
return el;
}
el = el.parentElement;
}
return document.scrollingElement || document.documentElement;
},
addHighlight(element) {
element.classList.add('highlight-pulse');
setTimeout(() => element.classList.remove('highlight-pulse'), 3100);
},
// 线性跳转到指定节点
async jumpToNode(nodeUuid) {
// 检查目标节点是否为脏数据
if (ClaudeAPI.conversationTree) {
const targetNode = ClaudeAPI.conversationTree.nodes[nodeUuid];
if (targetNode && targetNode._isDirtyData) {
console.error(`${LOG_PREFIX} 目标节点是脏数据,无法跳转: ${nodeUuid.slice(-8)}`);
return false;
}
}
// 首先更新当前线性分支状态
await ClaudeAPI.updateCurrentLinearBranch();
// 2.1 当前前端状态的线性分支包含该节点,直接跳转
if (ClaudeAPI.isNodeInCurrentBranch(nodeUuid)) {
this.jumpToNodeInCurrentBranch(nodeUuid);
await new Promise(resolve => setTimeout(resolve, 500));
return true;
} else {
// 2.2 当前前端状态的线性分支不包含该节点,执行跨分支跳转
console.log(`${LOG_PREFIX} 目标节点不在当前分支中,开始跨分支跳转: ${nodeUuid.slice(-8)}`);
// 调用分支切换器进行跨分支跳转
const switchSuccess = await BranchSwitcher.switchToTargetNode(nodeUuid);
if (switchSuccess) {
// 分支切换成功后,执行页面内跳转到目标节点
console.log(`${LOG_PREFIX} 分支切换成功,执行页面内跳转`);
await new Promise(resolve => setTimeout(resolve, 300));
// 更新分支状态并跳转
await ClaudeAPI.updateCurrentLinearBranch();
this.jumpToNodeInCurrentBranch(nodeUuid);
await new Promise(resolve => setTimeout(resolve, 500));
return true;
} else {
console.error(`${LOG_PREFIX} 跨分支跳转失败: ${nodeUuid.slice(-8)}`);
return false;
}
}
},
jumpToNodeInCurrentBranch(nodeUuid, cachedTurns = null) {
// 在当前分支中跳转
const element = document.getElementById(nodeUuid) ||
this.findElementByNodeUuid(nodeUuid, cachedTurns);
if (element) {
this.scrollToElement(element);
}
},
findElementByNodeUuid(nodeUuid, cachedTurns = null) {
// 直接通过ID查找
const directElement = document.getElementById(nodeUuid);
if (directElement) return directElement;
// 基于位置在当前线性分支中查找对应的DOM元素
if (!ClaudeAPI.currentLinearBranch) return null;
// 找到目标节点在当前分支中的索引
const nodeIndex = ClaudeAPI.currentLinearBranch.findIndex(node => node.uuid === nodeUuid);
if (nodeIndex === -1) return null;
// 使用缓存的turns或获取当前显示的所有回合
const turns = cachedTurns || ClaudeAPI.findCurrentTurns();
if (nodeIndex < turns.length) {
return turns[nodeIndex];
}
return null;
}
};
// =========================================================================
// 7. 线性对话索引管理
// =========================================================================
const LinearTurnIndex = {
generateId(index, urlHash = null) {
const hash = urlHash || location.pathname.split('/').pop() || 'default';
return `cpm-ln-turn-${hash}-${index + 1}`;
},
detectRole(turnElement) {
const isUser = !!turnElement.querySelector('[data-testid="user-message"]');
const isAssistant = !!turnElement.querySelector('.font-claude-response');
if (isUser) return 'user';
if (isAssistant) return 'assistant';
return null;
},
build() {
const turns = ClaudeAPI.findCurrentTurns();
if (!turns.length) return [];
const index = [];
for (let i = 0; i < turns.length; i++) {
const turnElement = turns[i];
const role = this.detectRole(turnElement);
if (!role) continue;
turnElement.setAttribute('data-cpm-ln-turn', '1');
const contentElement = turnElement.querySelector('[data-testid="user-message"]') ||
turnElement.querySelector('.font-claude-response') ||
turnElement;
let preview = TextUtils.getPreview(contentElement);
// 简化的附件检测逻辑:用户节点且无文本内容时显示附件图标
if (role === 'user' && !preview) {
preview = 'attachment';
}
if (!preview) continue;
if (!turnElement.id) {
turnElement.id = this.generateId(i);
}
index.push({
id: turnElement.id,
idx: i,
role,
preview,
element: turnElement
});
}
return index;
}
};
// =========================================================================
// 8. 线性导航UI组件
// =========================================================================
class LinearNavUI {
constructor() {
this.element = null;
this.isHovered = false;
this.currentActiveId = null;
this.isVisible = false;
}
create() {
this.element = this.createElement();
this.setupDrag();
this.bindEvents();
document.body.appendChild(this.element);
return this;
}
createElement() {
const nav = document.createElement('div');
nav.id = 'cpm-ln-nav';
nav.innerHTML = `
<div class="cpm-ln-header">
<div style="display: flex; align-items: center; margin-left: -4px;">
<button class="cpm-ln-refresh" type="button" title="刷新对话列表">
<svg class="cpm-svg-icon" style="width:14px; height:14px;"><use href="#cpm-ln-icon-refresh"></use></svg>
</button>
</div>
<div class="cpm-ln-title">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:16px; height:16px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg>
<span>线性导航</span>
</div>
<div style="display: flex; align-items: center; margin-right: -4px;">
<button class="cpm-ln-close" type="button" title="关闭线性导航">
<svg class="cpm-svg-icon" style="width:14px; height:14px;"><use href="#cpm-ln-icon-close"></use></svg>
</button>
</div>
</div>
<div class="cpm-ln-list"></div>
<div class="cpm-ln-footer">
<button class="cpm-ln-nav-btn" type="button" data-action="top" title="回到顶部">
<svg class="cpm-svg-icon"><use href="#cpm-ln-icon-arrow-line-up"></use></svg>
</button>
<button class="cpm-ln-nav-btn arrow" type="button" data-action="prev" title="上一条">
<svg class="cpm-svg-icon"><use href="#cpm-ln-icon-arrow-up"></use></svg>
</button>
<button class="cpm-ln-nav-btn arrow" type="button" data-action="next" title="下一条">
<svg class="cpm-svg-icon"><use href="#cpm-ln-icon-arrow-down"></use></svg>
</button>
<button class="cpm-ln-nav-btn" type="button" data-action="bottom" title="回到底部">
<svg class="cpm-svg-icon"><use href="#cpm-ln-icon-arrow-line-down"></use></svg>
</button>
</div>
`;
return nav;
}
setupDrag() {
const header = this.element.querySelector('.cpm-ln-header');
let isDragging = false, startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('button')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = this.element.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
this.element.style.left = `${startLeft + (e.clientX - startX)}px`;
this.element.style.top = `${startTop + (e.clientY - startY)}px`;
this.element.style.right = 'auto';
this.element.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => { isDragging = false; });
}
bindEvents() {
// 悬停状态
this.element.addEventListener('mouseenter', () => { this.isHovered = true; });
this.element.addEventListener('mouseleave', () => { this.isHovered = false; });
// 防止选择
this.element.addEventListener('dblclick', (e) => e.preventDefault(), { capture: true });
this.element.addEventListener('selectstart', (e) => e.preventDefault(), { capture: true });
this.element.addEventListener('mousedown', (e) => { if (e.detail > 1) e.preventDefault(); }, { capture: true });
// 关闭按钮
this.element.querySelector('.cpm-ln-close').addEventListener('click', () => {
this.hide();
});
// 刷新
this.element.querySelector('.cpm-ln-refresh').addEventListener('click', () => {
this.onRefresh();
});
// 导航按钮
this.element.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', (e) => {
const action = e.currentTarget.dataset.action;
this.onNavigate(action);
});
});
// 列表点击
const list = this.element.querySelector('.cpm-ln-list');
list.addEventListener('click', (e) => {
const item = e.target.closest('.cpm-ln-item');
if (item && item.dataset.id) {
this.onItemClick(item.dataset.id);
}
});
}
show() {
if (!this.isVisible) {
this.isVisible = true;
this.element.classList.add('visible');
StorageManager.setPanelState(true);
}
}
hide() {
if (this.isVisible) {
this.isVisible = false;
this.element.classList.remove('visible');
StorageManager.setPanelState(false);
}
}
toggle() {
if (this.isVisible) {
this.hide();
} else {
this.show();
this.onRefresh();
}
}
render(indexData) {
const list = this.element.querySelector('.cpm-ln-list');
if (!indexData.length) {
list.innerHTML = `<div class="cpm-ln-empty">暂无线性对话</div>`;
return;
}
list.innerHTML = '';
for (const item of indexData) {
const node = document.createElement('div');
node.className = `cpm-ln-item ${item.role}`;
node.dataset.id = item.id;
// 检查是否为附件格式并添加图标
const hasAttachmentFormat = item.preview === 'attachment';
if (hasAttachmentFormat) {
node.innerHTML = `
<span class="cpm-ln-number">${item.idx + 1}.</span>
<span class="cpm-ln-text" title="${escapeHTML(item.preview)}" style="display:inline-flex;align-items:center;white-space:nowrap;">
<svg class="cpm-svg-icon" style="width:12px;height:12px;margin-right:3px;vertical-align:middle;"><use href="#cpm-ln-icon-paperclip"></use></svg>${escapeHTML(item.preview)}
</span>
`;
} else {
node.innerHTML = `
<span class="cpm-ln-number">${item.idx + 1}.</span>
<span class="cpm-ln-text" title="${escapeHTML(item.preview)}">
${escapeHTML(item.preview)}
</span>
`;
}
node.setAttribute('draggable', 'false');
list.appendChild(node);
}
}
setActive(id) {
this.currentActiveId = id;
const list = this.element.querySelector('.cpm-ln-list');
list.querySelectorAll('.cpm-ln-item.active').forEach(n => n.classList.remove('active'));
const activeItem = list.querySelector(`.cpm-ln-item[data-id="${id}"]`);
if (activeItem) {
activeItem.classList.add('active');
// 确保激活项可见
const itemRect = activeItem.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
if (itemRect.top < listRect.top) {
list.scrollTop += itemRect.top - listRect.top - 4;
} else if (itemRect.bottom > listRect.bottom) {
list.scrollTop += itemRect.bottom - listRect.bottom + 4;
}
}
}
destroy() {
if (this.element) {
this.element.remove();
this.element = null;
}
}
// 事件回调(由外部设置)
onRefresh() {}
onNavigate() {}
onItemClick() {}
}
// =========================================================================
// 9. 共享UI与逻辑模块
// =========================================================================
const SharedLogic = {
async renderTreeView(container, messages, options = {}) {
const { isForBranching = false, isNavigationMode = false, onNodeClick = () => {} } = options;
container.innerHTML = '';
if (!messages || messages.length === 0) {
container.innerHTML = `<p class="cpm-loading">这是一个空对话${isForBranching ? ',无法选择节点' : ''}。</p>`;
return;
}
if (isForBranching && !isNavigationMode) {
const rootBtn = document.createElement('div');
rootBtn.id = 'cpm-branch-from-root-btn';
rootBtn.textContent = '从根节点开始 (创建一个新的主分支)';
rootBtn.onclick = () => onNodeClick(Config.INITIAL_PARENT_UUID, rootBtn);
container.appendChild(rootBtn);
}
const { nodes, childrenMap, rootNodes } = ClaudeAPI.buildConversationTree(messages);
const orgUuid = await ClaudeAPI.getOrgUuid();
const baseUrl = window.location.origin;
const renderNodeRecursive = (nodeUuid, indentLevel) => {
const node = nodes[nodeUuid];
if (!node) return;
const nodeElement = document.createElement('div');
nodeElement.className = 'cpm-tree-node';
nodeElement.style.paddingLeft = `${indentLevel * 0}px`;
const sender = node.sender === 'human' ? 'You' : 'Claude';
const retryMarker = node.input_mode === 'retry' ? ' [Retry]' : '';
let textContent = Array.isArray(node.content) ? node.content.filter(b => b.type === 'text' && b.text).map(b => b.text.replace(/\n/g, ' ')).join(' ') : '';
if (!textContent && node.text) textContent = node.text.replace(/\n/g, ' ');
const preview = textContent.substring(0, 80) + (textContent.length > 80 ? '...' : '');
let attachmentsHTML = '';
const allAttachments = [];
const files_uuids = new Set();
if (node.attachments) {
allAttachments.push(...node.attachments.map(file => ({ type: 'text', ...file })));
}
if (node.files) {
const binaryFiles = node.files.map(file => ({ type: 'binary', ...file }));
allAttachments.push(...binaryFiles);
binaryFiles.forEach(file => {
if (file.file_uuid) files_uuids.add(file.file_uuid);
});
}
if (node.files_v2) {
node.files_v2.forEach(file_v2 => {
if (!file_v2.file_uuid || !files_uuids.has(file_v2.file_uuid)) {
allAttachments.push({ type: 'binary', ...file_v2 });
}
});
}
if (allAttachments.length > 0) {
attachmentsHTML += '<div class="cpm-tree-attachments">└─ [附件]:<ul>';
allAttachments.forEach(file => {
if (file.type === 'text') {
const contentPreview = (file.extracted_content || '').substring(0, 25);
const escapedPreview = escapeHTML(contentPreview);
attachmentsHTML += `<li>- ${file.file_name} <span class="cpm-attachment-source">[Source: convert_document]</span> <span class="cpm-attachment-details">[ID: ${file.id}] [Preview: "${escapedPreview}..."]</span></li>`;
} else {
// 增强URL构造逻辑以支持blob类型
let fullUrl = '';
if (file.document_asset?.url) { // 优先使用显式URL
fullUrl = baseUrl + file.document_asset.url;
} else if (file.preview_url) { // 其次使用预览URL
fullUrl = baseUrl + file.preview_url;
} else if (file.file_kind === 'blob' && orgUuid && file.file_uuid) { // **新增**: 处理 blob 类型
fullUrl = `${baseUrl}/api/organizations/${orgUuid}/files/${file.file_uuid}/contents`;
} else if (orgUuid && file.file_uuid && file.file_name) { // 回退到旧的文档格式
const ext = file.file_name.includes('.') ? rsplit(file.file_name, '.', 1)[1] : '';
if (ext) fullUrl = `${baseUrl}/api/${orgUuid}/files/${file.file_uuid}/document_${ext.replace('.','')}/${file.file_name}`;
}
const urlLink = fullUrl ? `<a href="${fullUrl}" target="_blank" class="cpm-attachment-url" title="点击在新标签页打开: ${fullUrl}">[View/Download URL]</a>` : '[URL Not Available]';
attachmentsHTML += `<li>- ${file.file_name} <span class="cpm-attachment-source">[Source: /upload | Type: ${file.file_kind || 'unknown'}]</span> ${urlLink}</li>`;
}
});
attachmentsHTML += '</ul></div>';
}
// 检测是否为脏数据节点
const isDirtyNode = node._isDirtyData || (node.tree_id && node.tree_id.includes('-F'));
const dirtyClass = isDirtyNode ? ' cpm-dirty-node' : '';
const dirtyLabel = isDirtyNode ? ' [脏数据]' : '';
// 使用现代化DOM操作,避免HTML注入
const header = document.createElement('div');
header.className = `cpm-tree-node-header${dirtyClass}`;
const idSpan = document.createElement('span');
idSpan.className = 'cpm-tree-node-id';
idSpan.textContent = `[${node.tree_id}]`;
const senderSpan = document.createElement('span');
senderSpan.className = `cpm-tree-node-sender sender-${sender.toLowerCase()}`;
senderSpan.textContent = `${sender}${retryMarker}${dirtyLabel}:`;
const previewSpan = document.createElement('span');
previewSpan.className = 'cpm-tree-node-preview';
previewSpan.textContent = preview || '[仅包含附件或工具使用]';
header.append(idSpan, senderSpan, previewSpan);
nodeElement.appendChild(header);
// 附件HTML部分仍需要innerHTML(因为包含复杂HTML结构)
if (attachmentsHTML) {
const attachmentsDiv = document.createElement('div');
attachmentsDiv.innerHTML = attachmentsHTML;
nodeElement.appendChild(attachmentsDiv);
}
// 根据模式决定哪些节点可以点击(脏数据节点不可点击)
const isClickable = !isDirtyNode && (isNavigationMode ? true : (isForBranching && node.sender === 'assistant'));
if (isClickable) {
nodeElement.classList.add('cpm-node-clickable');
nodeElement.title = isNavigationMode ? '点击导航到此节点' : '点击从此节点继续对话';
nodeElement.onclick = () => onNodeClick(node.uuid, nodeElement);
}
container.appendChild(nodeElement);
(childrenMap[nodeUuid] || []).forEach(childUuid => renderNodeRecursive(childUuid, indentLevel + 1));
};
rootNodes.forEach(rootUuid => renderNodeRecursive(rootUuid, 0));
}
};
// =========================================================================
// 6. 业务逻辑层 (Service Layer)
// =========================================================================
const ManagerService = {
conversationsCache: [],
async loadConversations() {
this.conversationsCache = await ClaudeAPI.getConversations();
return this.conversationsCache;
},
async performManualRename(convUuid, newTitle) {
await ClaudeAPI.updateConversation(convUuid, { name: newTitle });
const cachedItem = this.conversationsCache.find(c => c.uuid === convUuid);
if (cachedItem) cachedItem.name = newTitle;
return true;
},
async exportAttachmentsForConversation(historyData, exportDirHandle, statusCallback) {
const { nodes } = ClaudeAPI.buildConversationTree(historyData.chat_messages);
const allAttachments = [];
for (const node of Object.values(nodes)) {
(node.attachments || []).forEach(file => allAttachments.push({ type: 'text', content: file.extracted_content, ...file }));
(node.files || []).forEach(file => allAttachments.push({ type: 'binary', ...file }));
(node.files_v2 || []).forEach(file => allAttachments.push({ type: 'binary', ...file }));
}
if (allAttachments.length > 0) {
statusCallback(`发现 ${allAttachments.length} 个附件,开始下载...`, 'info');
const orgInfo = await ClaudeAPI.getOrganizationInfo();
if (!orgInfo) throw new Error("无法获取组织信息以下载附件。");
for (let i = 0; i < allAttachments.length; i++) {
const file = allAttachments[i];
let fileName;
const baseName = file.file_name ? (file.file_name.includes('.') ? file.file_name.substring(0, file.file_name.lastIndexOf('.')) : file.file_name) : 'unknown_file';
const extension = file.file_name ? (file.file_name.includes('.') ? file.file_name.substring(file.file_name.lastIndexOf('.')) : '') : '';
if (file.type === 'text') {
fileName = `${baseName}_[${file.id || 'no-id'}].txt`;
} else if (file.type === 'binary' && file.file_uuid) {
fileName = `${baseName}_[${file.file_uuid}]${extension}`;
}
if (!fileName) continue;
try {
await exportDirHandle.getFileHandle(fileName, { create: false });
statusCallback(`(${i + 1}/${allAttachments.length}) 跳过 (文件已存在): ${fileName}`, 'info');
continue;
} catch (error) {
if (error.name !== 'NotFoundError') {
console.error(`检查文件 ${fileName} 时发生意外错误:`, error);
statusCallback(`检查文件 ${fileName} 出错`, 'error');
continue;
}
}
statusCallback(`(${i + 1}/${allAttachments.length}) 正在下载: ${fileName}`, 'info');
try {
let fileContent;
if (file.type === 'text') {
fileContent = new Blob([file.content || ""], { type: 'text/plain;charset=utf-8' });
} else {
// 增强URL构造逻辑以支持blob类型
let downloadUrl;
if (file.document_asset?.url) { // 优先使用显式URL
downloadUrl = file.document_asset.url;
} else if (file.preview_url) { // 其次使用预览URL
downloadUrl = file.preview_url;
} else if (file.file_kind === 'blob' && orgInfo.uuid && file.file_uuid) { // **新增**: 处理 blob 类型
downloadUrl = `/api/organizations/${orgInfo.uuid}/files/${file.file_uuid}/contents`;
} else if (orgInfo.uuid && file.file_uuid && file.file_name) { // 回退到旧的文档格式
const ext = file.file_name.includes('.') ? rsplit(file.file_name, '.', 1)[1] : '';
downloadUrl = `/api/${orgInfo.uuid}/files/${file.file_uuid}/document_${ext.replace('.','')}/${file.file_name}`;
}
if(!downloadUrl) throw new Error("找不到附件的下载链接。");
fileContent = await ClaudeAPI.downloadFile(downloadUrl);
}
const fileHandle = await exportDirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(fileContent);
await writable.close();
} catch (err) {
console.error(`处理附件 ${fileName} 失败:`, err);
statusCallback(`处理附件 ${fileName} 失败`, 'error');
}
}
}
},
async performExportOriginal(convUuid, statusCallback) {
if (typeof window.showDirectoryPicker !== 'function') throw new Error("您的浏览器不支持 File System Access API。");
statusCallback("正在请求文件夹权限...", 'info');
let rootDirHandle;
try {
rootDirHandle = await window.showDirectoryPicker();
} catch (err) {
if (err.name === 'AbortError') { statusCallback("用户取消了文件夹选择。", 'info', 3000); return; }
throw err;
}
try {
const historyData = await ClaudeAPI.getConversationHistory(convUuid);
const orgInfo = await ClaudeAPI.getOrganizationInfo();
if (!orgInfo) throw new Error("缺少导出所需组织信息。");
statusCallback("正在创建目录...", 'info');
const orgName = (orgInfo.name || "unknown_org").replace(/'s Organization$/, "");
const safeTitle = (historyData.name || "Untitled").replace(/[<>:"/\\|?*]/g, '_');
const pathParts = [`Claude_Exports`, `[${orgName}]`, `[Original]_[${safeTitle}]_[${convUuid}]`];
let currentDirHandle = rootDirHandle;
for (const part of pathParts) {
currentDirHandle = await currentDirHandle.getDirectoryHandle(part, { create: true });
}
const exportDirHandle = currentDirHandle;
const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
const historyFileName = `history-${timestamp}.json`;
statusCallback(`正在写入 ${historyFileName}...`, 'info');
const historyFileHandle = await exportDirHandle.getFileHandle(historyFileName, { create: true });
const writableHistory = await historyFileHandle.createWritable();
await writableHistory.write(JSON.stringify(historyData, null, 2));
await writableHistory.close();
await this.exportAttachmentsForConversation(historyData, exportDirHandle, statusCallback);
statusCallback("原始导出完成!", 'success', 5000);
} catch (error) {
console.error("原始导出失败:", error);
statusCallback(`原始导出失败: ${error.message}`, 'error', 5000);
}
},
transformConversation(originalData, settings) {
const newData = {};
if (settings.metadata.include) {
if (settings.metadata.title) newData.name = originalData.name;
if (settings.metadata.summary) newData.summary = originalData.summary;
if (settings.metadata.main_timestamps) {
newData.created_at = originalData.created_at;
newData.updated_at = originalData.updated_at;
}
if (settings.metadata.conv_settings) newData.settings = originalData.settings;
}
newData.chat_messages = originalData.chat_messages.map(originalMsg => {
const newMsg = { };
if (settings.message.sender) newMsg.sender = originalMsg.sender;
if (settings.message.uuids) {
newMsg.uuid = originalMsg.uuid;
newMsg.parent_message_uuid = originalMsg.parent_message_uuid;
}
if (settings.message.timestamps.messageNode) {
newMsg.created_at = originalMsg.created_at;
newMsg.updated_at = originalMsg.updated_at;
}
if (settings.message.other_meta) {
newMsg.index = originalMsg.index;
newMsg.stop_reason = originalMsg.stop_reason;
newMsg.truncated = originalMsg.truncated;
}
if (originalMsg.text) newMsg.text = originalMsg.text;
if (originalMsg.content && Array.isArray(originalMsg.content)) {
newMsg.content = originalMsg.content.map(block => {
const newBlock = {...block};
if (!settings.message.timestamps.contentBlock) {
delete newBlock.start_timestamp;
delete newBlock.stop_timestamp;
}
return newBlock;
}).filter(block => {
switch (block.type) {
case 'text': return settings.content.text;
case 'thinking': return settings.advanced.thinking;
case 'tool_use':
case 'tool_result':
if (!settings.advanced.tools.include) return false;
if (settings.advanced.tools.onlySuccessful && block.is_error) return false;
switch (block.name) {
case 'web_search': return settings.advanced.tools.web_search;
case 'repl': return settings.advanced.tools.repl;
case 'artifacts': return settings.advanced.tools.artifacts;
default: return settings.advanced.tools.other;
}
default: return true;
}
});
}
const processAttachments = (attachments) => {
if (!attachments) return undefined;
if (settings.attachments.mode === 'none') return undefined;
if (settings.attachments.mode === 'full') {
if (settings.message.timestamps.attachment) return attachments;
return attachments.map(att => { const newAtt = {...att}; delete newAtt.created_at; return newAtt; });
}
if (settings.attachments.mode === 'metadata_only') {
return attachments.map(att => ({
id: att.id, file_uuid: att.file_uuid, file_name: att.file_name,
file_size: att.file_size, file_type: att.file_type, file_kind: att.file_kind
}));
}
};
const attachmentsResult = processAttachments(originalMsg.attachments);
const filesResult = processAttachments(originalMsg.files);
const filesV2Result = processAttachments(originalMsg.files_v2);
if (attachmentsResult) newMsg.attachments = attachmentsResult;
if (filesResult) newMsg.files = filesResult;
if (filesV2Result) newMsg.files_v2 = filesV2Result;
return newMsg;
});
return newData;
},
async performExportCustom(convUuid, settings, statusCallback) {
if (typeof window.showDirectoryPicker !== 'function') throw new Error("您的浏览器不支持 File System Access API。");
statusCallback("正在请求文件夹权限...", 'info');
let rootDirHandle;
try {
rootDirHandle = await window.showDirectoryPicker();
} catch (err) {
if (err.name === 'AbortError') { statusCallback("用户取消了文件夹选择。", 'info', 3000); return; }
throw err;
}
try {
const historyData = await ClaudeAPI.getConversationHistory(convUuid);
const orgInfo = await ClaudeAPI.getOrganizationInfo();
if (!orgInfo) throw new Error("缺少导出所需组织信息。");
statusCallback("正在创建目录...", 'info');
const orgName = (orgInfo.name || "unknown_org").replace(/'s Organization$/, "");
const safeTitle = (historyData.name || "Untitled").replace(/[<>:"/\\|?*]/g, '_');
const pathParts = [`Claude_Exports`, `[${orgName}]`, `[Custom]_[${safeTitle}]_[${convUuid}]`];
let currentDirHandle = rootDirHandle;
for (const part of pathParts) {
currentDirHandle = await currentDirHandle.getDirectoryHandle(part, { create: true });
}
const exportDirHandle = currentDirHandle;
statusCallback("正在根据设置转换数据...", 'info');
const transformedData = this.transformConversation(historyData, settings);
const jsonString = JSON.stringify(transformedData, null, 2);
const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
const historyFileName = `history-${timestamp}.json`;
statusCallback(`正在写入 ${historyFileName}...`, 'info');
const historyFileHandle = await exportDirHandle.getFileHandle(historyFileName, { create: true });
const writableHistory = await historyFileHandle.createWritable();
await writableHistory.write(jsonString);
await writableHistory.close();
if (settings.attachments.mode !== 'none') {
await this.exportAttachmentsForConversation(historyData, exportDirHandle, statusCallback);
}
statusCallback("自定义导出完成!", 'success', 5000);
} catch (error) {
console.error("自定义导出失败:", error);
statusCallback(`自定义导出失败: ${error.message}`, 'error', 5000);
}
},
async performAutoRename(convUuid) {
const langPrompt = GM_getValue('renameLang', '中文');
const maxRounds = parseInt(GM_getValue('renameRounds', 2), 10);
const historyData = await ClaudeAPI.getConversationHistory(convUuid);
const roundsToUse = Math.min(Math.floor(historyData.chat_messages.length / 2), maxRounds);
if (roundsToUse < 1) throw new Error("对话轮次不足(可能为空对话),跳过重命名。");
const messagesToProcess = historyData.chat_messages.slice(0, roundsToUse * 2);
let messageParts = [];
messagesToProcess.forEach((msg, index) => {
const senderLabel = `Message ${index + 1} (${msg.sender === 'human' ? 'User' : 'Assistant'})`;
let textContent = Array.isArray(msg.content) ? msg.content.filter(b => b.type === 'text' && b.text).map(b => b.text).join('\n') : '';
if (!textContent && msg.text) textContent = msg.text;
if (textContent.trim()) messageParts.push(`${senderLabel}:\n\n${textContent.trim()}`);
});
if (messageParts.length === 0) throw new Error("在指定轮次内未找到有效文本内容。");
let finalMessageContent = messageParts.join('\n\n');
if (langPrompt && langPrompt.trim() !== "") {
const startInstruction = `TASK: Generate a title for the following conversation.\nRULE: The title language must be strictly ${langPrompt}.\n\n--- Conversation Start ---`;
const endInstruction = `\n--- Conversation End ---\nREMINDER: Generate the title in ${langPrompt} now.`;
finalMessageContent = `${startInstruction}\n\n${finalMessageContent}\n${endInstruction}`;
}
const tempConvUuid = await ClaudeAPI.createTempConversation();
try {
const newTitle = await ClaudeAPI.generateTitle(tempConvUuid, finalMessageContent);
await ClaudeAPI.updateConversation(convUuid, { name: newTitle });
const cachedItem = this.conversationsCache.find(c => c.uuid === convUuid);
if (cachedItem) cachedItem.name = newTitle;
return newTitle;
} finally {
await ClaudeAPI.deleteConversations([tempConvUuid]);
}
},
async performBatchStarAction(uuids, isStarring) {
let successCount = 0;
for (const uuid of uuids) {
try {
await ClaudeAPI.updateConversation(uuid, { is_starred: isStarring });
const cachedItem = this.conversationsCache.find(c => c.uuid === uuid);
if (cachedItem) cachedItem.is_starred = isStarring;
successCount++;
} catch (error) { console.error(`(取消)收藏 ${uuid} 失败:`, error); }
await new Promise(resolve => setTimeout(resolve, 300));
}
return successCount;
},
async performBatchDelete(uuids) {
await ClaudeAPI.deleteConversations(uuids);
this.conversationsCache = this.conversationsCache.filter(c => !uuids.includes(c.uuid));
return uuids.length;
}
};
// =========================================================================
// 7. 主管理器UI层 (ManagerUI)
// =========================================================================
const ManagerUI = {
currentSort: 'updated_at_desc',
currentFilter: 'all',
currentSearch: '',
statusTimeout: null,
isInitialized: false,
isManagerButtonVisible: true,
init() {
if (this.isInitialized) return;
this.createUI();
this.bindEvents();
ClaudeAPI.getOrgUuid().catch(err => console.error(LOG_PREFIX, "预获取OrgId失败", err));
this.isInitialized = true;
console.log(LOG_PREFIX, "主管理器UI已初始化。");
},
createUI() {
const svgDefs = document.createElement('div');
svgDefs.style.display = 'none';
svgDefs.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="cpm-icon-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></symbol>
<symbol id="cpm-icon-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></symbol>
<symbol id="cpm-icon-edit" viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></symbol>
<symbol id="cpm-icon-tree" viewBox="0 0 24 24"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></symbol>
<symbol id="cpm-icon-export-original" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></symbol>
<symbol id="cpm-icon-export-custom" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /><g transform="translate(16, 3) scale(0.5)" fill="currentColor" stroke="none"><path fill-rule="evenodd" d="M6.455 1.45A.5.5 0 0 1 6.952 1h2.096a.5.5 0 0 1 .497.45l.186 1.858a4.996 4.996 0 0 1 1.466.848l1.703-.769a.5.5 0 0 1 .639.206l1.047 1.814a.5.5 0 0 1-.14.656l-1.517 1.09a5.026 5.026 0 0 1 0 1.694l1.516 1.09a.5.5 0 0 1 .141.656l-1.047 1.814a.5.5 0 0 1-.639.206l-1.703-.768c-.433.36-.928.649-1.466.847l-.186 1.858a.5.5 0 0 1-.497.45H6.952a.5.5 0 0 1-.497-.45l-.186-1.858a4.993 4.993 0 0 1-1.466-.848l-1.703.769a.5.5 0 0 1-.639-.206l-1.047-1.814a.5.5 0 0 1 .14-.656l1.517-1.09a5.033 5.033 0 0 1 0-1.694l-1.516-1.09a.5.5 0 0 1-.141-.656L2.46 3.593a.5.5 0 0 1 .639-.206l1.703.769c.433-.36.928.65 1.466-.848l.186-1.858Zm-.177 7.567-.022-.037a2 2 0 0 1 3.466-1.997l.022.037a2 2 0 0 1-3.466 1.997Z" clip-rule="evenodd" /></g></symbol>
<symbol id="cpm-icon-save" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"></path></symbol>
<symbol id="cpm-icon-cancel" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></symbol>
<symbol id="cpm-icon-github" viewBox="0 0 24 24"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></symbol>
<symbol id="cpm-icon-tree-studio" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="6" y1="3" x2="6" y2="15" stroke-linecap="round"></line><circle cx="16" cy="8" r="2"></circle><circle cx="6" cy="18" r="2"></circle><path d="M16 11a8 8 0 0 1 -8 7" stroke-linecap="round"></path><g transform="translate(14.5, 14.5) scale(0.5)" fill="currentColor" stroke="none"><path fill-rule="evenodd" d="M6.455 1.45A.5.5 0 0 1 6.952 1h2.096a.5.5 0 0 1 .497.45l.186 1.858a4.996 4.996 0 0 1 1.466.848l1.703-.769a.5.5 0 0 1 .639.206l1.047 1.814a.5.5 0 0 1-.14.656l-1.517 1.09a5.026 5.026 0 0 1 0 1.694l1.516 1.09a.5.5 0 0 1 .141.656l-1.047 1.814a.5.5 0 0 1-.639.206l-1.703-.768c-.433.36-.928.649-1.466.847l-.186 1.858a.5.5 0 0 1-.497.45H6.952a.5.5 0 0 1-.497-.45l-.186-1.858a4.993 4.993 0 0 1-1.466-.848l-1.703.769a.5.5 0 0 1-.639-.206l-1.047-1.814a.5.5 0 0 1 .14-.656l1.517-1.09a5.033 5.033 0 0 1 0-1.694l-1.516-1.09a.5.5 0 0 1-.141-.656L2.46 3.593a.5.5 0 0 1 .639-.206l1.703.769c.433-.36.928.65 1.466-.848l.186-1.858Zm-.177 7.567-.022-.037a2 2 0 0 1 3.466-1.997l.022.037a2 2 0 0 1-3.466 1.997Z" clip-rule="evenodd" /></g></symbol>
<symbol id="cpm-icon-attachment" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12-3-3m0 0-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></symbol>
<symbol id="cpm-icon-pdf-mode-off" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></symbol>
<symbol id="cpm-icon-pdf-mode-on" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></symbol>
<symbol id="cpm-icon-help" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></symbol>
<symbol id="cpm-ln-icon-paperclip" viewBox="0 0 20 20" fill="currentColor" stroke-width="0.5">
<g transform="scale(1.2) translate(-1.67, -1.67)">
<path d="M6.0678 2.16105C7.46414 1.61127 9.04215 2.29797 9.59221 3.69425L12.7983 11.8339C13.1238 12.6606 12.7177 13.5952 11.891 13.9208L11.8149 13.9511C10.9883 14.2765 10.0535 13.8695 9.72795 13.0429L8.02678 8.7255C7.92565 8.46868 8.05228 8.17836 8.30901 8.07706C8.56594 7.97586 8.85624 8.10236 8.95744 8.35929L10.6586 12.6767C10.7819 12.9894 11.1359 13.1436 11.4487 13.0204L11.5248 12.9901C11.8377 12.8669 11.9908 12.5129 11.8676 12.2001L8.66155 4.06046C8.31383 3.17839 7.31727 2.74467 6.43498 3.09171L6.28069 3.15226C5.39843 3.49996 4.96466 4.4974 5.31194 5.3798L9.18108 15.2011C9.75314 16.6533 11.3938 17.3667 12.8461 16.7948L13.0766 16.705C14.5288 16.1329 15.2432 14.4913 14.6713 13.039L12.308 7.03898C12.2069 6.78212 12.3325 6.49177 12.5893 6.39054C12.8461 6.28961 13.1365 6.41605 13.2377 6.67277L15.601 12.6728C16.3753 14.6389 15.4089 16.8601 13.4428 17.6347L13.2133 17.7255C11.2472 18.4998 9.025 17.5342 8.25041 15.5683L4.38225 5.74698C3.83217 4.35052 4.51801 2.77168 5.91448 2.22159L6.0678 2.16105Z"/>
</g>
</symbol>
<symbol id="cpm-ln-icon-linear-navigator" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</symbol>
<symbol id="cpm-ln-icon-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</symbol>
<symbol id="cpm-ln-icon-arrow-line-up" viewBox="0 0 256 256" fill="currentColor">
<path d="M205.66,138.34a8,8,0,0,1-11.32,11.32L136,91.31V224a8,8,0,0,1-16,0V91.31L61.66,149.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0ZM216,32H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"></path>
</symbol>
<symbol id="cpm-ln-icon-arrow-line-down" viewBox="0 0 256 256" fill="currentColor">
<path d="M50.34,117.66a8,8,0,0,1,11.32-11.32L120,164.69V32a8,8,0,0,1,16,0V164.69l58.34-58.35a8,8,0,0,1,11.32,11.32l-72,72a8,8,0,0,1-11.32,0ZM216,208H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"></path>
</symbol>
<symbol id="cpm-ln-icon-arrow-up" viewBox="0 0 256 256" fill="currentColor">
<path d="M205.66,117.66a8,8,0,0,1-11.32,0L136,59.31V216a8,8,0,0,1-16,0V59.31L61.66,117.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0l72,72A8,8,0,0,1,205.66,117.66Z"></path>
</symbol>
<symbol id="cpm-ln-icon-arrow-down" viewBox="0 0 256 256" fill="currentColor">
<path d="M205.66,149.66l-72,72a8,8,0,0,1-11.32,0l-72-72a8,8,0,0,1,11.32-11.32L120,196.69V40a8,8,0,0,1,16,0V196.69l58.34-58.35a8,8,0,0,1,11.32,11.32Z"></path>
</symbol>
<symbol id="cpm-ln-icon-close" viewBox="0 0 256 256" fill="currentColor">
<path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"></path>
</symbol>
<symbol id="cpm-icon-close" viewBox="0 0 256 256" fill="currentColor"><path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"></path></symbol>
<symbol id="cpm-icon-batch-export-original" viewBox="0 0 24 24"><path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /><text x="2" y="7" font-family="Arial, Helvetica, sans-serif" font-size="5" fill="currentColor" stroke="none">bat</text></symbol>
<symbol id="cpm-icon-batch-export-custom" viewBox="0 0 24 24"><path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /><text x="2" y="7" font-family="Arial, Helvetica, sans-serif" font-size="5" fill="currentColor" stroke="none">bat</text><g transform="translate(16, 3) scale(0.5)" fill="currentColor" stroke="none"><path fill-rule="evenodd" d="M6.455 1.45A.5.5 0 0 1 6.952 1h2.096a.5.5 0 0 1 .497.45l.186 1.858a4.996 4.996 0 0 1 1.466.848l1.703-.769a.5.5 0 0 1 .639.206l1.047 1.814a.5.5 0 0 1-.14.656l-1.517 1.09a5.026 5.026 0 0 1 0 1.694l1.516 1.09a.5.5 0 0 1 .141.656l-1.047 1.814a.5.5 0 0 1-.639.206l-1.703-.768c-.433.36-.928.649-1.466.847l-.186 1.858a.5.5 0 0 1-.497.45H6.952a.5.5 0 0 1-.497-.45l-.186-1.858a4.993 4.993 0 0 1-1.466-.848l-1.703.769a.5.5 0 0 1-.639-.206l-1.047-1.814a.5.5 0 0 1 .14-.656l1.517-1.09a5.033 5.033 0 0 1 0-1.694l-1.516-1.09a.5.5 0 0 1-.141-.656L2.46 3.593a.5.5 0 0 1 .639-.206l1.703.769c.433-.36.928.65 1.466-.848l.186-1.858Zm-.177 7.567-.022-.037a2 2 0 0 1 3.466-1.997l.022.037a2 2 0 0 1-3.466 1.997Z" clip-rule="evenodd" /></g></symbol>
</defs>
</svg>
`;
document.body.appendChild(svgDefs);
const managerButton = document.createElement('button');
managerButton.id = 'cpm-manager-button';
managerButton.innerHTML = 'Manager';
managerButton.title = 'Tips: Ctrl + M 可以隐藏此按钮';
document.body.appendChild(managerButton);
const mainPanel = document.createElement('div');
mainPanel.id = 'cpm-main-panel';
mainPanel.className = 'cpm-panel';
mainPanel.innerHTML = `
<div class="cpm-header">
<h2>Manager</h2>
<div class="cpm-header-actions">
<a href="${Config.URL_GITHUB_REPO}" target="_blank" class="cpm-icon-btn" title="查看 GitHub 仓库"><svg class="cpm-svg-icon" stroke-width="1.5"><use href="#cpm-icon-github"></use></svg></a>
<a href="${Config.URL_STUDIO_REPO}" target="_blank" class="cpm-icon-btn" title="了解下一个项目: claude-dialog-tree-studio"><svg class="cpm-svg-icon" stroke-width="1.5"><use href="#cpm-icon-tree-studio"></use></svg></a>
<button id="cpm-open-settings-button" title="设置" class="cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-settings"></use></svg></button>
<button class="cpm-close-button cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-close"></use></svg></button>
</div>
</div>
<div class="cpm-toolbar">
<div class="cpm-toolbar-group"><button class="cpm-btn" id="cpm-select-all">全选</button><button class="cpm-btn" id="cpm-select-none">全不选</button><button class="cpm-btn" id="cpm-select-invert">反选</button></div>
<div class="cpm-toolbar-group"><input type="search" id="cpm-search-box" placeholder="搜索标题..."/></div>
<div class="cpm-toolbar-group"><label>排序:</label><select id="cpm-sort-select"><option value="updated_at_desc">时间降序</option><option value="updated_at_asc">时间升序</option><option value="name_asc">名称 A-Z</option><option value="name_desc">名称 Z-A</option></select></div>
<div class="cpm-toolbar-group"><label>筛选:</label><select id="cpm-filter-select"><option value="all">显示全部</option><option value="starred">仅显示收藏</option><option value="unstarred">隐藏收藏</option><option value="ascii_only">仅显示纯ASCII标题</option><option value="non_ascii">不显示纯ASCII标题</option></select></div>
<button class="cpm-icon-btn" id="cpm-refresh" title="刷新列表"><svg class="cpm-svg-icon"><use href="#cpm-icon-refresh"></use></svg></button>
</div>
<div class="cpm-actions"><button class="cpm-action-btn" id="cpm-batch-star">批量收藏</button><button class="cpm-action-btn" id="cpm-batch-unstar">批量取消收藏</button><button class="cpm-action-btn" id="cpm-batch-rename">批量自动重命名</button><button class="cpm-action-btn cpm-danger-btn" id="cpm-batch-delete">批量删除</button><span style="flex-grow: 1;"></span><button class="cpm-icon-btn cpm-batch-export-btn" id="cpm-batch-export-original" title="批量原始JSON导出"><svg class="cpm-svg-icon" style="width:20px; height:20px;" stroke-width="1.5"><use href="#cpm-icon-batch-export-original"></use></svg></button><button class="cpm-icon-btn cpm-batch-export-btn" id="cpm-batch-export-custom" title="批量自定义JSON导出"><svg class="cpm-svg-icon" style="width:20px; height:20px;" stroke-width="1.5"><use href="#cpm-icon-batch-export-custom"></use></svg></button></div>
<div class="cpm-list-container"><p class="cpm-loading">点击刷新按钮 ( <svg class="cpm-svg-icon"><use href="#cpm-icon-refresh"></use></svg> ) 加载会话列表。</p></div>
<div class="cpm-status-bar">准备就绪。</div>`;
document.body.appendChild(mainPanel);
const settingsPanel = document.createElement('div');
settingsPanel.id = 'cpm-settings-panel';
settingsPanel.className = 'cpm-panel';
const settingsHeader = `<div class="cpm-header"><h2>管理器设置</h2><button class="cpm-close-button cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-close"></use></svg></button></div>`;
const settingsContent = document.createElement('div');
settingsContent.className = 'cpm-settings-content';
for (const module of SettingsRegistry.modules) {
const section = document.createElement('div');
section.className = 'cpm-setting-section';
section.innerHTML = `<h3 class="cpm-setting-section-title">${module.title}</h3>` + module.render();
settingsContent.appendChild(section);
}
const settingsButtons = `<div class="cpm-settings-buttons"><button id="cpm-back-to-main" class="cpm-btn">返回主面板</button><button id="cpm-save-settings-button" class="cpm-btn cpm-primary-btn">保存设置</button></div>`;
settingsPanel.innerHTML = settingsHeader;
settingsPanel.appendChild(settingsContent);
settingsPanel.insertAdjacentHTML('beforeend', settingsButtons);
document.body.appendChild(settingsPanel);
const treePanel = document.createElement('div');
treePanel.id = 'cpm-tree-panel';
treePanel.className = 'cpm-panel cpm-tree-panel-override';
treePanel.innerHTML = `
<div class="cpm-header"><h2 id="cpm-tree-title">对话树预览</h2><button id="cpm-tree-close-button" class="cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-close"></use></svg></button></div>
<div id="cpm-tree-container" class="cpm-tree-container"><p class="cpm-loading">正在加载对话树...</p></div>`;
document.body.appendChild(treePanel);
},
bindEvents() {
document.getElementById('cpm-manager-button').onclick = () => this.togglePanel('cpm-main-panel');
document.querySelectorAll('.cpm-close-button').forEach(btn => btn.onclick = () => this.hideAllPanels());
document.getElementById('cpm-open-settings-button').onclick = () => this.togglePanel('cpm-settings-panel');
document.getElementById('cpm-back-to-main').onclick = () => this.togglePanel('cpm-main-panel');
document.getElementById('cpm-refresh').onclick = () => this.loadConversations();
document.getElementById('cpm-select-all').onclick = () => this.selectAll(true);
document.getElementById('cpm-select-none').onclick = () => this.selectAll(false);
document.getElementById('cpm-select-invert').onclick = () => this.selectInvert();
document.getElementById('cpm-search-box').oninput = (e) => { this.currentSearch = e.target.value; this.renderConversationList(); };
document.getElementById('cpm-sort-select').onchange = (e) => { this.currentSort = e.target.value; this.renderConversationList(); };
document.getElementById('cpm-filter-select').onchange = (e) => { this.currentFilter = e.target.value; this.renderConversationList(); };
document.getElementById('cpm-batch-rename').onclick = () => this.handleBatchRename();
document.getElementById('cpm-batch-delete').onclick = () => this.handleBatchDelete();
document.getElementById('cpm-batch-star').onclick = () => this.handleBatchStar(true);
document.getElementById('cpm-batch-unstar').onclick = () => this.handleBatchStar(false);
document.getElementById('cpm-batch-export-original').onclick = () => this.handleBatchExport('original');
document.getElementById('cpm-batch-export-custom').onclick = () => this.handleBatchExport('custom');
document.getElementById('cpm-save-settings-button').onclick = () => this.saveSettings();
document.getElementById('cpm-tree-close-button').onclick = () => this.hidePanel('cpm-tree-panel');
// 添加Ctrl+M键盘快捷键监听
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'm') {
e.preventDefault();
this.toggleManagerButtonVisibility();
}
});
document.querySelector('#cpm-main-panel .cpm-list-container').addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
const uuid = li.dataset.uuid;
if (e.target.closest('.cpm-action-rename')) this.enterEditMode(li);
else if (e.target.closest('.cpm-action-tree')) this.handleTreeView(uuid);
else if (e.target.closest('.cpm-action-export-original')) this.handleExport(uuid, 'original');
else if (e.target.closest('.cpm-action-export-custom')) this.handleExport(uuid, 'custom');
else if (e.target.closest('.cpm-action-save')) this.handleSaveRename(li);
else if (e.target.closest('.cpm-action-cancel')) this.exitEditMode(li);
});
},
togglePanel(panelId) {
const panel = document.getElementById(panelId);
const isVisible = panel.style.display === 'flex';
this.hideAllPanels();
if (!isVisible) {
panel.style.display = 'flex';
if (panelId === 'cpm-main-panel' && ManagerService.conversationsCache.length === 0) this.loadConversations();
if (panelId === 'cpm-settings-panel') this.loadSettings();
}
},
hidePanel(panelId) { document.getElementById(panelId).style.display = 'none'; },
hideAllPanels() {
document.querySelectorAll('.cpm-panel').forEach(p => p.style.display = 'none');
document.querySelector('.cpm-modal-overlay')?.remove();
},
loadSettings() {
const panel = document.getElementById('cpm-settings-panel');
if (!panel) return;
for (const module of SettingsRegistry.modules) {
module.load(panel);
module.addEventListeners?.(panel);
}
},
saveSettings() {
const panel = document.getElementById('cpm-settings-panel');
if (!panel) return;
for (const module of SettingsRegistry.modules) {
module.save(panel);
}
this.updateStatus('设置已保存!', 'success', 3000);
this.togglePanel('cpm-main-panel');
},
async loadConversations() {
const listContainer = document.querySelector('#cpm-main-panel .cpm-list-container');
listContainer.innerHTML = '<p class="cpm-loading">正在加载会话列表...</p>';
this.updateStatus("正在获取会话列表...", 'info');
try {
const convos = await ManagerService.loadConversations();
this.renderConversationList();
this.updateStatus(`已加载 ${convos.length} 个会话。`, 'info');
} catch (error) {
listContainer.innerHTML = `<p class="cpm-error">加载会话失败: ${error.message}</p>`;
this.updateStatus("加载失败。", 'error');
}
},
escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); },
renderConversationList() {
const listContainer = document.querySelector('#cpm-main-panel .cpm-list-container');
let conversationsToRender = [...ManagerService.conversationsCache];
if (this.currentSearch) {
const searchPattern = new RegExp(this.escapeRegExp(this.currentSearch), 'i');
conversationsToRender = conversationsToRender.filter(c => searchPattern.test(c.name || ''));
}
if (this.currentFilter === 'starred') conversationsToRender = conversationsToRender.filter(c => c.is_starred);
else if (this.currentFilter === 'unstarred') conversationsToRender = conversationsToRender.filter(c => !c.is_starred);
else if (this.currentFilter === 'ascii_only') conversationsToRender = conversationsToRender.filter(c => /^[\x00-\x7F]*$/.test(c.name || ''));
else if (this.currentFilter === 'non_ascii') conversationsToRender = conversationsToRender.filter(c => /[^\x00-\x7F]/.test(c.name || ''));
conversationsToRender.sort((a, b) => {
switch (this.currentSort) {
case 'updated_at_asc': return new Date(a.updated_at) - new Date(b.updated_at);
case 'name_asc': return (a.name || '').localeCompare(b.name || '');
case 'name_desc': return (b.name || '').localeCompare(a.name || '');
default: return new Date(b.updated_at) - new Date(a.updated_at);
}
});
if (conversationsToRender.length === 0) { listContainer.innerHTML = '<p>没有符合条件的会话。</p>'; return; }
const ul = document.createElement('ul');
ul.className = 'cpm-convo-list';
conversationsToRender.forEach(convo => {
const li = document.createElement('li');
li.dataset.uuid = convo.uuid;
const titleText = convo.name || '无标题对话';
let highlightedTitle = titleText;
if (this.currentSearch) highlightedTitle = titleText.replace(new RegExp(this.escapeRegExp(this.currentSearch), 'gi'), (match) => `<span class="cpm-highlight">${match}</span>`);
const star = convo.is_starred ? '<span class="cpm-star">★</span>' : '';
li.innerHTML = `
<input type="checkbox" class="cpm-checkbox" data-uuid="${convo.uuid}">
<div class="cpm-convo-details"><span class="cpm-convo-title">${star}${highlightedTitle}</span><span class="cpm-convo-date">${new Date(convo.updated_at).toLocaleString()}</span></div>
<div class="cpm-convo-actions">
<button class="cpm-icon-btn cpm-action-rename" title="手动重命名"><svg class="cpm-svg-icon"><use href="#cpm-icon-edit"></use></svg></button>
<button class="cpm-icon-btn cpm-action-tree" title="预览对话树"><svg class="cpm-svg-icon"><use href="#cpm-icon-tree"></use></svg></button>
<button class="cpm-icon-btn cpm-action-export-original" title="原始JSON导出"><svg class="cpm-svg-icon" stroke-width="1.5"><use href="#cpm-icon-export-original"></use></svg></button>
<button class="cpm-icon-btn cpm-action-export-custom" title="自定义JSON导出"><svg class="cpm-svg-icon" stroke-width="1.5"><use href="#cpm-icon-export-custom"></use></svg></button>
</div>`;
ul.appendChild(li);
});
listContainer.innerHTML = '';
listContainer.appendChild(ul);
},
enterEditMode(li) {
const currentlyEditing = document.querySelector('li.is-editing');
if (currentlyEditing && currentlyEditing !== li) this.exitEditMode(currentlyEditing);
li.classList.add('is-editing');
const detailsDiv = li.querySelector('.cpm-convo-details');
const actionsDiv = li.querySelector('.cpm-convo-actions');
const titleSpan = li.querySelector('.cpm-convo-title');
const originalTitle = titleSpan.textContent.replace(/★/g, '').trim();
li.dataset.originalDetails = detailsDiv.innerHTML;
li.dataset.originalActions = actionsDiv.innerHTML;
detailsDiv.innerHTML = `<input type="text" class="cpm-edit-input" value="${escapeHTML(originalTitle)}">`;
actionsDiv.innerHTML = `<button class="cpm-icon-btn cpm-action-save" title="保存"><svg class="cpm-svg-icon"><use href="#cpm-icon-save"></use></svg></button><button class="cpm-icon-btn cpm-action-cancel" title="取消"><svg class="cpm-svg-icon"><use href="#cpm-icon-cancel"></use></svg></button>`;
const input = detailsDiv.querySelector('.cpm-edit-input');
input.focus();
input.select();
input.onkeydown = (e) => {
if (e.key === 'Enter') { e.preventDefault(); this.handleSaveRename(li); }
else if (e.key === 'Escape') { this.exitEditMode(li); }
};
},
exitEditMode(li) {
if (!li.classList.contains('is-editing')) return;
li.classList.remove('is-editing');
li.querySelector('.cpm-convo-details').innerHTML = li.dataset.originalDetails;
li.querySelector('.cpm-convo-actions').innerHTML = li.dataset.originalActions;
delete li.dataset.originalDetails;
delete li.dataset.originalActions;
},
async handleSaveRename(li) {
const uuid = li.dataset.uuid;
const input = li.querySelector('.cpm-edit-input');
const newTitle = input.value.trim();
const originalTitle = li.dataset.originalDetails.match(/<span class="cpm-convo-title">(.*?)<\/span>/)[1].replace(/<[^>]*>/g, '').replace(/★/g, '').trim();
if (!newTitle || newTitle === originalTitle) { this.exitEditMode(li); return; }
input.disabled = true;
this.updateStatus(`正在保存新标题...`, 'info');
try {
await ManagerService.performManualRename(uuid, newTitle);
this.updateStatus("保存成功!", 'success');
const convo = ManagerService.conversationsCache.find(c => c.uuid === uuid);
const star = convo.is_starred ? '<span class="cpm-star">★</span>' : '';
li.dataset.originalDetails = li.dataset.originalDetails.replace(/>(★)?.*?<\/span>/, `>${star}${newTitle}</span>`);
this.exitEditMode(li);
} catch (error) {
this.updateStatus(`保存失败: ${error.message}`, 'error');
input.disabled = false;
input.focus();
}
},
async handleTreeView(uuid) {
const treePanel = document.getElementById('cpm-tree-panel');
const treeContainer = document.getElementById('cpm-tree-container');
const treeTitle = document.getElementById('cpm-tree-title');
const convo = ManagerService.conversationsCache.find(c => c.uuid === uuid);
treeTitle.textContent = `对话树: ${convo ? (convo.name || '无标题') : '加载中...'}`;
treeContainer.innerHTML = '<p class="cpm-loading">正在加载对话历史...</p>';
treePanel.style.display = 'flex';
try {
const historyData = await ClaudeAPI.getConversationHistory(uuid);
await SharedLogic.renderTreeView(treeContainer, historyData.chat_messages);
} catch (error) {
console.error(error);
treeContainer.innerHTML = `<p class="cpm-error">无法加载对话树: ${error.message}</p>`;
}
},
async handleExport(uuid, type) {
if (type === 'original') {
await ManagerService.performExportOriginal(uuid, this.updateStatus.bind(this));
} else if (type === 'custom') {
this.showExportModal(uuid);
}
},
selectAll(checked) { document.querySelectorAll('.cpm-list-container .cpm-checkbox').forEach(cb => cb.checked = checked); },
selectInvert() { document.querySelectorAll('.cpm-list-container .cpm-checkbox').forEach(cb => cb.checked = !cb.checked); },
getSelectedUuids() { return Array.from(document.querySelectorAll('.cpm-checkbox:checked')).map(cb => cb.dataset.uuid); },
updateStatus(message, type = 'info', timeout = 0) {
if (this.statusTimeout) clearTimeout(this.statusTimeout);
const s = document.querySelector('#cpm-main-panel .cpm-status-bar');
s.textContent = message;
s.classList.remove('is-error', 'is-success');
if (type === 'error') s.classList.add('is-error');
else if (type === 'success') s.classList.add('is-success');
if (timeout > 0) {
this.statusTimeout = setTimeout(() => {
s.textContent = '准备就绪。';
s.classList.remove('is-error', 'is-success');
}, timeout);
}
},
async handleBatchOperation(opName, serviceFunc, ...args) {
const uuids = this.getSelectedUuids();
if (uuids.length === 0) { alert(`请选择要执行“${opName}”的会话。`); return; }
if (opName.includes('删除') && !confirm(`确定永久删除 ${uuids.length} 个会话吗?`)) return;
document.querySelectorAll('.cpm-action-btn').forEach(btn => btn.disabled = true);
this.updateStatus(`正在批量${opName} ${uuids.length} 个会话...`, 'info');
let successCount = 0;
try {
if (opName.includes('重命名')) {
for (let i = 0; i < uuids.length; i++) {
this.updateStatus(`正在${opName} ${i + 1}/${uuids.length}...`, 'info');
try {
const newTitle = await serviceFunc(uuids[i]);
const titleElement = document.querySelector(`li[data-uuid="${uuids[i]}"] .cpm-convo-title`);
if (titleElement) {
const star = titleElement.querySelector('.cpm-star');
titleElement.innerHTML = `${star ? star.outerHTML : ''}${newTitle}`;
titleElement.style.color = 'hsl(var(--cpm-success-000))';
}
successCount++;
} catch (error) {
const titleElement = document.querySelector(`li[data-uuid="${uuids[i]}"] .cpm-convo-title`);
if(titleElement) { titleElement.style.color = 'hsl(var(--cpm-danger-000))'; }
this.updateStatus(`第${i+1}个失败: ${error.message}`, 'error');
await new Promise(resolve => setTimeout(resolve, 1500));
}
if (i < uuids.length - 1) await new Promise(resolve => setTimeout(resolve, 300));
}
} else {
successCount = await serviceFunc(uuids, ...args);
}
this.updateStatus(`操作完成。成功${opName} ${successCount}/${uuids.length} 个会话。`, 'success', 4000);
} catch(e) { this.updateStatus(`批量${opName}失败: ${e.message}`, 'error', 5000); }
const refreshSettingKey = opName.includes('删除') ? 'refreshAfterDelete' : opName.includes('收藏') ? 'refreshAfterStar' : 'refreshAfterRename';
if (GM_getValue(refreshSettingKey, false)) {
this.updateStatus(document.querySelector('#cpm-main-panel .cpm-status-bar').textContent + ' 正在从服务器刷新列表...', 'info');
await this.loadConversations();
} else { this.renderConversationList(); }
document.querySelectorAll('.cpm-action-btn').forEach(btn => btn.disabled = false);
},
handleBatchRename() { this.handleBatchOperation('重命名', ManagerService.performAutoRename.bind(ManagerService)); },
handleBatchDelete() { this.handleBatchOperation('删除', ManagerService.performBatchDelete.bind(ManagerService)); },
handleBatchStar(isStarring) { this.handleBatchOperation(isStarring ? '收藏' : '取消收藏', ManagerService.performBatchStarAction.bind(ManagerService), isStarring); },
handleBatchExport(type) {
const uuids = this.getSelectedUuids();
if (uuids.length === 0) { alert('请选择要导出的会话。'); return; }
if (type === 'original') {
this.performBatchExportOriginal(uuids);
} else if (type === 'custom') {
this.showBatchExportModal(uuids);
}
},
async performBatchExportOriginal(uuids) {
if (typeof window.showDirectoryPicker !== 'function') {
alert('您的浏览器不支持 File System Access API。');
return;
}
this.updateStatus(`准备批量导出 ${uuids.length} 个会话...`, 'info');
let rootDirHandle;
try {
rootDirHandle = await window.showDirectoryPicker();
} catch (err) {
if (err.name === 'AbortError') {
this.updateStatus("用户取消了文件夹选择。", 'info', 3000);
return;
}
throw err;
}
let successCount = 0;
for (let i = 0; i < uuids.length; i++) {
const uuid = uuids[i];
const convo = ManagerService.conversationsCache.find(c => c.uuid === uuid);
const title = convo ? (convo.name || '无标题') : '加载中...';
this.updateStatus(`(${i + 1}/${uuids.length}) 正在导出: ${title}`, 'info');
try {
await this.exportSingleConversation(uuid, rootDirHandle, 'original');
successCount++;
} catch (error) {
console.error(`导出会话 ${uuid} 失败:`, error);
this.updateStatus(`导出失败 (${i + 1}/${uuids.length}): ${error.message}`, 'error');
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (i < uuids.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
this.updateStatus(`批量导出完成: ${successCount}/${uuids.length} 个会话成功导出。`, 'success', 5000);
},
async exportSingleConversation(uuid, rootDirHandle, type) {
const historyData = await ClaudeAPI.getConversationHistory(uuid);
const orgInfo = await ClaudeAPI.getOrganizationInfo();
if (!orgInfo) throw new Error("缺少导出所需组织信息。");
const orgName = (orgInfo.name || "unknown_org").replace(/'s Organization$/, "");
const safeTitle = (historyData.name || "Untitled").replace(/[<>:"/\\|?*]/g, '_');
const pathParts = [`Claude_Exports`, `[${orgName}]`, `[${type === 'original' ? 'Original' : 'Custom'}]_[${safeTitle}]_[${uuid}]`];
let currentDirHandle = rootDirHandle;
for (const part of pathParts) {
currentDirHandle = await currentDirHandle.getDirectoryHandle(part, { create: true });
}
const exportDirHandle = currentDirHandle;
const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
const historyFileName = `history-${timestamp}.json`;
let dataToWrite;
if (type === 'original') {
dataToWrite = historyData;
} else if (type === 'custom') {
const settings = this.tempBatchExportSettings;
dataToWrite = ManagerService.transformConversation(historyData, settings);
}
const historyFileHandle = await exportDirHandle.getFileHandle(historyFileName, { create: true });
const writableHistory = await historyFileHandle.createWritable();
await writableHistory.write(JSON.stringify(dataToWrite, null, 2));
await writableHistory.close();
if (type === 'original' || (type === 'custom' && this.tempBatchExportSettings.attachments.mode !== 'none')) {
await ManagerService.exportAttachmentsForConversation(historyData, exportDirHandle, () => {});
}
},
showBatchExportModal(uuids) {
document.querySelector('.cpm-modal-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'cpm-modal-overlay';
const modalContent = document.createElement('div');
modalContent.className = 'cpm-panel cpm-export-modal-content';
modalContent.style.display = 'flex';
modalContent.innerHTML = `
<div class="cpm-header">
<h2>批量自定义导出选项 (${uuids.length} 个会话)</h2>
<button class="cpm-close-button cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-close"></use></svg></button>
</div>
<div class="cpm-settings-content">
${this.createExportSettingsHTML(false)}
</div>
<div class="cpm-settings-buttons">
<button id="cpm-batch-export-now-btn" class="cpm-btn cpm-primary-btn">开始批量导出</button>
</div>
`;
overlay.appendChild(modalContent);
document.body.appendChild(overlay);
this.loadExportSettings(modalContent);
this.setupSubOptionDisabling(modalContent);
overlay.onclick = (e) => { if(e.target === overlay) overlay.remove(); };
modalContent.querySelector('.cpm-close-button').onclick = () => overlay.remove();
modalContent.querySelector('#cpm-batch-export-now-btn').onclick = async () => {
try {
const currentSettings = this.getExportSettings(modalContent);
this.tempBatchExportSettings = currentSettings;
modalContent.querySelector('#cpm-batch-export-now-btn').disabled = true;
modalContent.querySelector('#cpm-batch-export-now-btn').textContent = '准备导出...';
overlay.remove();
await this.performBatchExportCustom(uuids);
} catch (error) {
console.error(`${LOG_PREFIX} 批量导出失败:`, error);
this.updateStatus(`批量导出失败: ${error.message}`, 'error');
}
};
},
async performBatchExportCustom(uuids) {
if (typeof window.showDirectoryPicker !== 'function') {
alert('您的浏览器不支持 File System Access API。');
return;
}
this.updateStatus(`准备批量自定义导出 ${uuids.length} 个会话...`, 'info');
let rootDirHandle;
try {
rootDirHandle = await window.showDirectoryPicker();
} catch (err) {
if (err.name === 'AbortError') {
this.updateStatus("用户取消了文件夹选择。", 'info', 3000);
return;
}
throw err;
}
let successCount = 0;
for (let i = 0; i < uuids.length; i++) {
const uuid = uuids[i];
const convo = ManagerService.conversationsCache.find(c => c.uuid === uuid);
const title = convo ? (convo.name || '无标题') : '加载中...';
this.updateStatus(`(${i + 1}/${uuids.length}) 正在导出: ${title}`, 'info');
try {
await this.exportSingleConversation(uuid, rootDirHandle, 'custom');
successCount++;
} catch (error) {
console.error(`导出会话 ${uuid} 失败:`, error);
this.updateStatus(`导出失败 (${i + 1}/${uuids.length}): ${error.message}`, 'error');
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (i < uuids.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
delete this.tempBatchExportSettings;
this.updateStatus(`批量自定义导出完成: ${successCount}/${uuids.length} 个会话成功导出。`, 'success', 5000);
},
createExportSettingsHTML(forSettingsPanel = false) {
const maybeRemoveTitle = forSettingsPanel ? '' : '<h3 class="cpm-setting-section-title">自定义导出默认设置</h3>';
return `
${maybeRemoveTitle}
<div class="cpm-setting-group" data-section="export-metadata">
<h4>基础信息</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-meta-include"><label for="cpm-export-meta-include">保留会话元数据</label></div>
<div class="cpm-setting-sub-group" data-parent="meta-include">
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-meta-title"><label for="cpm-export-meta-title">标题 (name)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-meta-summary"><label for="cpm-export-meta-summary">摘要 (summary)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-meta-main-timestamps"><label for="cpm-export-meta-main-timestamps">会话创建/更新时间</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-meta-conv-settings"><label for="cpm-export-meta-conv-settings">会话设置 (settings)</label></div>
</div>
</div>
<div class="cpm-setting-group" data-section="export-message">
<h4>消息结构</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-msg-sender"><label for="cpm-export-msg-sender">发送者 (sender)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-msg-uuids"><label for="cpm-export-msg-uuids">消息/父级UUID (建议保留)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-msg-other-meta"><label for="cpm-export-msg-other-meta">其他元数据 (index, stop_reason等)</label></div>
</div>
<div class="cpm-setting-group" data-section="export-timestamps">
<h4>时间戳信息</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-ts-message"><label for="cpm-export-ts-message">消息节点时间戳 (created_at/updated_at)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-ts-content"><label for="cpm-export-ts-content">内容块流式时间戳 (start/stop)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-ts-attachment"><label for="cpm-export-ts-attachment">附件创建时间戳</label></div>
</div>
<div class="cpm-setting-group" data-section="export-content">
<h4>核心内容</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-content-text"><label for="cpm-export-content-text">文本内容 (text块)</label></div>
<div class="cpm-setting-item">
<label class="cpm-settings-label">附件信息:</label>
<select id="cpm-export-attachments-mode">
<option value="full">完整信息 (含提取文本)</option>
<option value="metadata_only">仅元数据 (文件名,大小等)</option>
<option value="none">不保留附件</option>
</select>
</div>
</div>
<div class="cpm-setting-group" data-section="export-advanced">
<h4>高级内容</h4>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-thinking"><label for="cpm-export-adv-thinking">'思考'过程 (thinking块)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-tools-include"><label for="cpm-export-adv-tools-include">保留工具使用记录</label></div>
<div class="cpm-setting-sub-group" data-parent="adv-tools-include">
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-tool-websearch"><label for="cpm-export-adv-tool-websearch">网页搜索 (web_search)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-tool-repl"><label for="cpm-export-adv-tool-repl">代码分析 (repl)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-tool-artifacts"><label for="cpm-export-adv-tool-artifacts">工件创建 (artifacts)</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-tool-other"><label for="cpm-export-adv-tool-other">其他未知工具</label></div>
<div class="cpm-setting-item"><input type="checkbox" id="cpm-export-adv-tool-only-successful"><label for="cpm-export-adv-tool-only-successful">仅保留成功的工具调用</label></div>
</div>
</div>
`;
},
getExportSettings(container) {
return {
metadata: {
include: container.querySelector('#cpm-export-meta-include').checked,
title: container.querySelector('#cpm-export-meta-title').checked,
summary: container.querySelector('#cpm-export-meta-summary').checked,
main_timestamps: container.querySelector('#cpm-export-meta-main-timestamps').checked,
conv_settings: container.querySelector('#cpm-export-meta-conv-settings').checked,
},
message: {
sender: container.querySelector('#cpm-export-msg-sender').checked,
uuids: container.querySelector('#cpm-export-msg-uuids').checked,
other_meta: container.querySelector('#cpm-export-msg-other-meta').checked,
timestamps: {
messageNode: container.querySelector('#cpm-export-ts-message').checked,
contentBlock: container.querySelector('#cpm-export-ts-content').checked,
attachment: container.querySelector('#cpm-export-ts-attachment').checked,
}
},
content: {
text: container.querySelector('#cpm-export-content-text').checked,
},
attachments: {
mode: container.querySelector('#cpm-export-attachments-mode').value,
},
advanced: {
thinking: container.querySelector('#cpm-export-adv-thinking').checked,
tools: {
include: container.querySelector('#cpm-export-adv-tools-include').checked,
web_search: container.querySelector('#cpm-export-adv-tool-websearch').checked,
repl: container.querySelector('#cpm-export-adv-tool-repl').checked,
artifacts: container.querySelector('#cpm-export-adv-tool-artifacts').checked,
other: container.querySelector('#cpm-export-adv-tool-other').checked,
onlySuccessful: container.querySelector('#cpm-export-adv-tool-only-successful').checked,
}
}
};
},
loadExportSettings(container) {
const prefix = 'exportDefault_';
const settings = {
metadata: {
include: GM_getValue(`${prefix}meta_include`, true), title: GM_getValue(`${prefix}meta_title`, true),
summary: GM_getValue(`${prefix}meta_summary`, false), main_timestamps: GM_getValue(`${prefix}meta_main_timestamps`, false),
conv_settings: GM_getValue(`${prefix}meta_conv_settings`, false),
},
message: {
sender: GM_getValue(`${prefix}msg_sender`, true), uuids: GM_getValue(`${prefix}msg_uuids`, true),
other_meta: GM_getValue(`${prefix}msg_other_meta`, false),
timestamps: {
messageNode: GM_getValue(`${prefix}ts_message`, false),
contentBlock: GM_getValue(`${prefix}ts_content`, false),
attachment: GM_getValue(`${prefix}ts_attachment`, false),
}
},
content: { text: GM_getValue(`${prefix}content_text`, true) },
attachments: { mode: GM_getValue(`${prefix}attachments_mode`, 'full') },
advanced: {
thinking: GM_getValue(`${prefix}adv_thinking`, true),
tools: {
include: GM_getValue(`${prefix}adv_tools_include`, true), web_search: GM_getValue(`${prefix}adv_tool_websearch`, true),
repl: GM_getValue(`${prefix}adv_tool_repl`, true), artifacts: GM_getValue(`${prefix}adv_tool_artifacts`, true),
other: GM_getValue(`${prefix}adv_tool_other`, true), onlySuccessful: GM_getValue(`${prefix}adv_tool_only_successful`, false),
}
}
};
container.querySelector('#cpm-export-meta-include').checked = settings.metadata.include;
container.querySelector('#cpm-export-meta-title').checked = settings.metadata.title;
container.querySelector('#cpm-export-meta-summary').checked = settings.metadata.summary;
container.querySelector('#cpm-export-meta-main-timestamps').checked = settings.metadata.main_timestamps;
container.querySelector('#cpm-export-meta-conv-settings').checked = settings.metadata.conv_settings;
container.querySelector('#cpm-export-msg-sender').checked = settings.message.sender;
container.querySelector('#cpm-export-msg-uuids').checked = settings.message.uuids;
container.querySelector('#cpm-export-msg-other-meta').checked = settings.message.other_meta;
container.querySelector('#cpm-export-ts-message').checked = settings.message.timestamps.messageNode;
container.querySelector('#cpm-export-ts-content').checked = settings.message.timestamps.contentBlock;
container.querySelector('#cpm-export-ts-attachment').checked = settings.message.timestamps.attachment;
container.querySelector('#cpm-export-content-text').checked = settings.content.text;
container.querySelector('#cpm-export-attachments-mode').value = settings.attachments.mode;
container.querySelector('#cpm-export-adv-thinking').checked = settings.advanced.thinking;
container.querySelector('#cpm-export-adv-tools-include').checked = settings.advanced.tools.include;
container.querySelector('#cpm-export-adv-tool-websearch').checked = settings.advanced.tools.web_search;
container.querySelector('#cpm-export-adv-tool-repl').checked = settings.advanced.tools.repl;
container.querySelector('#cpm-export-adv-tool-artifacts').checked = settings.advanced.tools.artifacts;
container.querySelector('#cpm-export-adv-tool-other').checked = settings.advanced.tools.other;
container.querySelector('#cpm-export-adv-tool-only-successful').checked = settings.advanced.tools.onlySuccessful;
},
saveExportSettings(container) {
const settings = this.getExportSettings(container);
const prefix = 'exportDefault_';
GM_setValue(`${prefix}meta_include`, settings.metadata.include);
GM_setValue(`${prefix}meta_title`, settings.metadata.title);
GM_setValue(`${prefix}meta_summary`, settings.metadata.summary);
GM_setValue(`${prefix}meta_main_timestamps`, settings.metadata.main_timestamps);
GM_setValue(`${prefix}meta_conv_settings`, settings.metadata.conv_settings);
GM_setValue(`${prefix}msg_sender`, settings.message.sender);
GM_setValue(`${prefix}msg_uuids`, settings.message.uuids);
GM_setValue(`${prefix}msg_other_meta`, settings.message.other_meta);
GM_setValue(`${prefix}ts_message`, settings.message.timestamps.messageNode);
GM_setValue(`${prefix}ts_content`, settings.message.timestamps.contentBlock);
GM_setValue(`${prefix}ts_attachment`, settings.message.timestamps.attachment);
GM_setValue(`${prefix}content_text`, settings.content.text);
GM_setValue(`${prefix}attachments_mode`, settings.attachments.mode);
GM_setValue(`${prefix}adv_thinking`, settings.advanced.thinking);
GM_setValue(`${prefix}adv_tools_include`, settings.advanced.tools.include);
GM_setValue(`${prefix}adv_tool_websearch`, settings.advanced.tools.web_search);
GM_setValue(`${prefix}adv_tool_repl`, settings.advanced.tools.repl);
GM_setValue(`${prefix}adv_tool_artifacts`, settings.advanced.tools.artifacts);
GM_setValue(`${prefix}adv_tool_other`, settings.advanced.tools.other);
GM_setValue(`${prefix}adv_tool_only_successful`, settings.advanced.tools.onlySuccessful);
},
setupSubOptionDisabling(container) {
const setupListener = (parentId, subGroupSelector) => {
const parentCheckbox = container.querySelector(parentId);
const subItems = container.querySelectorAll(subGroupSelector);
if (!parentCheckbox || subItems.length === 0) return;
const updateState = () => {
const isDisabled = !parentCheckbox.checked;
subItems.forEach(item => {
item.querySelectorAll('input, select').forEach(el => el.disabled = isDisabled);
item.classList.toggle('disabled', isDisabled);
});
};
parentCheckbox.addEventListener('change', updateState);
updateState();
};
setupListener('#cpm-export-meta-include', '.cpm-setting-sub-group[data-parent="meta-include"] .cpm-setting-item');
setupListener('#cpm-export-adv-tools-include', '.cpm-setting-sub-group[data-parent="adv-tools-include"] .cpm-setting-item');
},
showExportModal(uuid) {
document.querySelector('.cpm-modal-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'cpm-modal-overlay';
const modalContent = document.createElement('div');
modalContent.className = 'cpm-panel cpm-export-modal-content';
modalContent.style.display = 'flex';
modalContent.innerHTML = `
<div class="cpm-header"><h2>自定义导出选项</h2><button class="cpm-close-button cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-close"></use></svg></button></div>
<div class="cpm-settings-content">
${this.createExportSettingsHTML(false)}
</div>
<div class="cpm-settings-buttons">
<button id="cpm-export-now-btn" class="cpm-btn cpm-primary-btn">立即导出</button>
</div>
`;
overlay.appendChild(modalContent);
document.body.appendChild(overlay);
this.loadExportSettings(modalContent);
this.setupSubOptionDisabling(modalContent);
overlay.onclick = (e) => { if(e.target === overlay) overlay.remove(); };
modalContent.querySelector('.cpm-close-button').onclick = () => overlay.remove();
modalContent.querySelector('#cpm-export-now-btn').onclick = async () => {
try {
const currentSettings = this.getExportSettings(modalContent);
modalContent.querySelector('#cpm-export-now-btn').disabled = true;
modalContent.querySelector('#cpm-export-now-btn').textContent = '正在导出...';
await ManagerService.performExportCustom(uuid, currentSettings, this.updateStatus.bind(this));
overlay.remove();
} catch (error) {
console.error(`${LOG_PREFIX} 导出失败:`, error);
this.updateStatus(`导出失败: ${error.message}`, 'error');
modalContent.querySelector('#cpm-export-now-btn').disabled = false;
modalContent.querySelector('#cpm-export-now-btn').textContent = '立即导出';
}
};
},
toggleManagerButtonVisibility() {
this.isManagerButtonVisible = !this.isManagerButtonVisible;
const managerButton = document.getElementById('cpm-manager-button');
if (managerButton) {
managerButton.style.display = this.isManagerButtonVisible ? 'block' : 'none';
console.log(LOG_PREFIX, `Manager按钮已${this.isManagerButtonVisible ? '显示' : '隐藏'} (Ctrl+M)`);
}
}
};
// =========================================================================
// 8. 聊天增强模块 (Enhancer Modules)
// =========================================================================
const NavigatorEnhancer = {
state: {
conversationUUID: null,
selectedParentMessageUUID: null,
currentMode: 'branch' // 'branch' 或 'navigate'
},
init() {
this.cleanup();
this.createNavigatorButton();
},
updateState(currentUrl) {
const pathParts = new URL(currentUrl).pathname.split('/');
this.state.conversationUUID = (pathParts[1] === 'chat' && pathParts[2]) ? pathParts[2] : null;
if (!this.state.conversationUUID) this.state.selectedParentMessageUUID = null;
this.updateStatusIndicator();
},
createNavigatorButton() {
if (document.getElementById('cpm-branch-btn')) return;
const toolbar = document.querySelector(Config.TOOLBAR_SELECTOR);
if (!toolbar) return;
const emptyArea = toolbar.querySelector(Config.EMPTY_AREA_SELECTOR);
if (!emptyArea) return;
const wrapperDiv = document.createElement('div');
wrapperDiv.className = "relative shrink-0";
const button = document.createElement('button');
button.id = 'cpm-branch-btn';
button.type = 'button';
button.title = '从对话历史的任意节点延续&导航至任意节点';
button.className = "inline-flex items-center justify-center relative shrink-0 can-focus select-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none border-0.5 transition-all h-8 min-w-8 rounded-lg flex items-center px-[7.5px] group !pointer-events-auto !outline-offset-1 text-text-300 border-border-300 active:scale-[0.98] hover:text-text-200/90 hover:bg-bg-100";
button.innerHTML = `<div class="flex flex-row items-center justify-center gap-1"><svg class="cpm-svg-icon" style="width:16px; height:16px; stroke-width:1.8;"><use href="#cpm-icon-tree"></use></svg></div>`;
button.onclick = () => this.showModal();
wrapperDiv.appendChild(button);
toolbar.insertBefore(wrapperDiv, emptyArea);
},
async showModal() {
const overlay = document.createElement('div');
overlay.className = 'cpm-modal-overlay';
overlay.onclick = () => overlay.remove();
const modalContent = document.createElement('div');
modalContent.className = 'cpm-panel cpm-navigator-panel-override';
modalContent.style.display = 'flex';
modalContent.onclick = (e) => e.stopPropagation();
modalContent.innerHTML = `
<div class="cpm-header">
<h2>对话节点延续&导航器</h2>
<button id="cpm-navigator-modal-close-btn" class="cpm-icon-btn"><svg class="cpm-svg-icon"><use href="#cpm-icon-close"></use></svg></button>
</div>
<div class="cpm-mode-selector">
<button id="cpm-branch-mode-btn" class="cpm-mode-btn ${this.state.currentMode === 'branch' ? 'active' : ''}">延续模式</button>
<button id="cpm-navigate-mode-btn" class="cpm-mode-btn ${this.state.currentMode === 'navigate' ? 'active' : ''}">导航模式</button>
</div>
<div id="cpm-navigator-tree-container" class="cpm-tree-container"></div>`;
overlay.appendChild(modalContent);
document.body.appendChild(overlay);
// 绑定事件
overlay.querySelector('#cpm-navigator-modal-close-btn').onclick = () => overlay.remove();
overlay.querySelector('#cpm-branch-mode-btn').onclick = () => this.switchMode('branch', modalContent);
overlay.querySelector('#cpm-navigate-mode-btn').onclick = () => this.switchMode('navigate', modalContent);
// 加载内容
await this.loadModalContent(modalContent);
},
switchMode(mode, modalContent) {
this.state.currentMode = mode;
modalContent.querySelector('.cpm-mode-btn.active')?.classList.remove('active');
modalContent.querySelector(`#cpm-${mode === 'branch' ? 'branch' : 'navigate'}-mode-btn`).classList.add('active');
this.loadModalContent(modalContent);
},
async loadModalContent(modalContent) {
const treeContainer = modalContent.querySelector('#cpm-navigator-tree-container');
if (this.state.conversationUUID) {
treeContainer.innerHTML = '<p class="cpm-loading">正在加载对话历史...</p>';
try {
// 使用智能缓存机制,避免重复请求
await ClaudeAPI.tryInitializeConversationTree();
if (!ClaudeAPI.conversationTree) {
throw new Error('无法获取对话数据');
}
// 从缓存的对话树获取消息数据
const messages = Object.values(ClaudeAPI.conversationTree.nodes);
await SharedLogic.renderTreeView(treeContainer, messages, {
isForBranching: this.state.currentMode === 'branch',
isNavigationMode: this.state.currentMode === 'navigate',
onNodeClick: (uuid, element) => this.handleNodeClick(uuid, element)
});
} catch (error) {
treeContainer.innerHTML = `<p class="cpm-error">加载失败: ${error.message}</p>`;
}
} else {
treeContainer.innerHTML = '<p class="cpm-loading">不在具体聊天内,无法操作节点。</p>';
}
},
handleNodeClick(uuid, element) {
if (this.state.currentMode === 'branch') {
// 延续模式:设置分支点
this.selectBranchPoint(uuid, element);
} else {
// 导航模式:直接跳转
this.navigateToNode(uuid, element);
}
},
selectBranchPoint(uuid, element) {
this.state.selectedParentMessageUUID = uuid;
document.querySelectorAll('.cpm-node-selected').forEach(n => n.classList.remove('cpm-node-selected'));
element.classList.add('cpm-node-selected');
this.updateStatusIndicator();
setTimeout(() => document.querySelector('.cpm-modal-overlay')?.remove(), 300);
},
async navigateToNode(uuid, element) {
document.querySelectorAll('.cpm-node-selected').forEach(n => n.classList.remove('cpm-node-selected'));
element.classList.add('cpm-node-selected');
// 立即关闭面板
setTimeout(() => document.querySelector('.cpm-modal-overlay')?.remove(), 300);
// 执行导航(跨分支跳转)
LinearNavigator.jumpToNode(uuid);
},
updateStatusIndicator() {
const toolbar = document.querySelector(Config.TOOLBAR_SELECTOR);
if (!toolbar) return;
document.getElementById('cpm-branch-status-indicator')?.remove();
if (this.state.selectedParentMessageUUID) {
const indicator = document.createElement('span');
indicator.id = 'cpm-branch-status-indicator';
indicator.textContent = '分支点已选定';
indicator.title = `下条消息将从指定节点开始。\nUUID: ${this.state.selectedParentMessageUUID}`;
toolbar.appendChild(indicator);
}
},
cleanup() {
document.querySelector('#cpm-branch-btn')?.closest('div.relative.shrink-0').remove();
document.getElementById('cpm-branch-status-indicator')?.remove();
}
};
const LinearNavEnhancer = {
ui: null,
currentUrl: location.href,
refreshTimer: 0,
forceRefreshTimer: null,
observer: null,
isBooting: false,
init() {
this.cleanup();
this.createLinearNavigatorButton();
// 检查是否需要自动启动面板
this.checkAutoStart();
},
cleanup() {
document.querySelector('#cpm-ln-linear-navigator-btn')?.closest('div.relative.shrink-0').remove();
if (this.ui) {
this.ui.destroy();
this.ui = null;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.forceRefreshTimer) {
clearInterval(this.forceRefreshTimer);
this.forceRefreshTimer = null;
}
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = 0;
}
},
createLinearNavigatorButton() {
if (document.getElementById('cpm-ln-linear-navigator-btn')) return;
const toolbar = document.querySelector(Config.TOOLBAR_SELECTOR);
if (!toolbar) return;
const emptyArea = toolbar.querySelector(Config.EMPTY_AREA_SELECTOR);
if (!emptyArea) return;
const wrapperDiv = document.createElement('div');
wrapperDiv.className = "relative shrink-0";
const button = document.createElement('button');
button.id = 'cpm-ln-linear-navigator-btn';
button.type = 'button';
button.title = '线性导航';
button.className = "inline-flex items-center justify-center relative shrink-0 can-focus select-none disabled:pointer-events-none disabled:shadow-none disabled:drop-shadow-none border-0.5 transition-all h-8 min-w-8 rounded-lg flex items-center px-[7.5px] group !pointer-events-auto !outline-offset-1 text-text-300 border-border-300 active:scale-[0.98] hover:text-text-200/90 hover:bg-bg-100";
button.style.fontWeight = "normal";
button.innerHTML = `<div class="flex flex-row items-center justify-center gap-1"><svg class="cpm-svg-icon" style="width:16px; height:16px;"><use href="#cpm-ln-icon-linear-navigator"></use></svg></div>`;
button.onclick = () => this.toggleLinearNavigator();
wrapperDiv.appendChild(button);
toolbar.insertBefore(wrapperDiv, emptyArea);
},
checkAutoStart() {
// 延迟检查,确保页面元素完全加载
const tryAutoStart = (attempt = 0) => {
const maxAttempts = 5;
const delay = 2000 + (attempt * 1000); // 逐渐增加延迟
setTimeout(() => {
// 检查页面是否稳定(工具栏存在且没有正在进行清理)
const toolbar = document.querySelector(Config.TOOLBAR_SELECTOR);
const hasButton = document.getElementById('cpm-ln-linear-navigator-btn');
if (toolbar && hasButton && StorageManager.getPanelState()) {
// 确保不会与现有的UI冲突
if (!this.ui) {
this.toggleLinearNavigator();
}
} else if (attempt < maxAttempts && toolbar) {
// 如果页面还不稳定但工具栏存在,继续尝试
tryAutoStart(attempt + 1);
}
}, delay);
};
tryAutoStart();
},
toggleLinearNavigator() {
if (!this.ui) {
this.boot();
}
this.ui.toggle();
},
boot() {
if (this.ui || this.isBooting) return;
this.isBooting = true;
try {
this.ui = new LinearNavUI().create();
this.setupUICallbacks();
this.setupObserver();
this.setupEventListeners();
this.startAutoRefresh();
} finally {
this.isBooting = false;
}
},
setupUICallbacks() {
this.ui.onRefresh = () => this.refresh({ ignoreHover: true, force: true });
this.ui.onNavigate = (action) => this.navigate(action);
this.ui.onItemClick = (id) => this.jumpToItem(id);
},
setupObserver() {
if (this.observer) this.observer.disconnect();
this.observer = new MutationObserver(() => {
this.refresh({ delay: Config.refreshInterval });
});
this.observer.observe(document.body, { childList: true, subtree: true, attributes: true });
},
setupEventListeners() {
// 发送消息后的快速刷新
const handleSend = () => this.burstRefresh();
document.addEventListener('click', (e) => {
if (e.target.closest('button[type="submit"], [aria-label*="Send"]')) {
handleSend();
}
}, true);
document.addEventListener('keydown', (e) => {
const target = e.target;
if ((target.tagName === 'TEXTAREA' || target.isContentEditable) &&
e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSend();
}
}, true);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) this.refresh({ force: true });
});
},
startAutoRefresh() {
if (this.forceRefreshTimer) clearInterval(this.forceRefreshTimer);
this.forceRefreshTimer = setInterval(() => {
this.refresh({ force: true });
}, 10000); // 10秒自动刷新
},
refresh({ delay = 80, force = false, ignoreHover = false } = {}) {
if (this.ui && this.ui.isHovered && !ignoreHover) return;
if (force) {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = 0;
}
this.doRefresh();
return;
}
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => {
this.refreshTimer = 0;
this.doRefresh();
}, delay);
},
doRefresh() {
if (!this.ui) return;
try {
const indexData = LinearTurnIndex.build();
this.ui.render(indexData);
} catch (e) {
console.error('Linear refresh error:', e);
}
},
burstRefresh(duration = 6000, interval = 160) {
const endTime = Date.now() + duration;
const tick = () => {
this.refresh({ force: true, ignoreHover: true });
if (Date.now() < endTime) {
setTimeout(tick, interval);
}
};
tick();
},
navigate(action) {
const indexData = LinearTurnIndex.build();
if (!indexData.length) return;
if (action === 'top' || action === 'bottom') {
const turns = ClaudeAPI.findCurrentTurns();
if (!turns.length) return;
const targetTurn = action === 'top' ? turns[0] : turns[turns.length - 1];
const topMargin = action === 'bottom' ? -window.innerHeight : Config.topMargin;
LinearNavigator.scrollToElement(targetTurn, topMargin);
if (targetTurn && targetTurn.id) {
setTimeout(() => this.ui.setActive(targetTurn.id), 150);
}
return;
}
const currentIndex = indexData.findIndex(item => item.id === this.ui.currentActiveId);
const delta = action === 'prev' ? -1 : 1;
let nextIndex;
if (currentIndex < 0) {
nextIndex = delta > 0 ? 0 : indexData.length - 1;
} else {
nextIndex = Math.max(0, Math.min(indexData.length - 1, currentIndex + delta));
}
const nextItem = indexData[nextIndex];
if (nextItem) {
this.jumpToItem(nextItem.id);
}
},
jumpToItem(id) {
const element = document.getElementById(id);
if (element) {
this.ui.setActive(id);
LinearNavigator.scrollToElement(element);
}
},
updateState(currentUrl) {
if (currentUrl !== this.currentUrl) {
this.currentUrl = currentUrl;
if (this.ui && this.ui.isVisible) {
this.refresh({ force: true });
}
}
}
};
const AttachmentEnhancer = {
state: {
forceUploadMode: 'default',
stagedAttachments: [],
},
panelObserver: null,
init() {
this.cleanup();
this.createAttachmentPowerButton();
if (this.state.stagedAttachments.length > 0) {
this.showPreviewPanel();
}
},
createAttachmentPowerButton() {
if (document.getElementById('cpm-attachment-power-btn')) return;
const toolbar = document.querySelector(Config.TOOLBAR_SELECTOR);
const emptyArea = toolbar?.querySelector(Config.EMPTY_AREA_SELECTOR);
if (!toolbar || !emptyArea) return;
const wrapperDiv = document.createElement('div');
wrapperDiv.className = "relative shrink-0";
wrapperDiv.innerHTML = `
<button class="inline-flex items-center justify-center relative shrink-0 can-focus select-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none border-0.5 transition-all h-8 min-w-8 rounded-lg flex items-center px-[7.5px] group !pointer-events-auto !outline-offset-1 text-text-300 border-border-300 active:scale-[0.98] hover:text-text-200/90 hover:bg-bg-100" type="button" id="cpm-attachment-power-btn" aria-label="打开PDF上传设置">
<div class="flex flex-row items-center justify-center gap-1"><svg class="cpm-svg-icon" style="width:16px; height:16px; stroke-width:1.8;"><use href="#cpm-icon-attachment"></use></svg></div>
</button>
<div class="w-[24rem] absolute max-w-[calc(100vw-16px)] bottom-10 block hidden" id="cpm-attachment-power-menu">
<div class="relative w-full will-change-transform h-auto overflow-y-auto overscroll-auto flex z-dropdown bg-bg-000 rounded-lg overflow-hidden border-border-300 border-0.5 shadow-diffused shadow-[hsl(var(--always-black)/6%)] flex-col-reverse" style="max-height: 340px;">
<div class="flex flex-col min-h-0 w-full !ease-out justify-end" style="height: auto;">
<div class="w-full">
<div class="p-1.5 flex flex-col">
<button class="group flex w-full items-center text-left gap-2.5 py-auto px-1.5 text-[0.875rem] text-text-200 rounded-md transition-colors select-none active:!scale-100 hover:bg-bg-200/50 hover:text-text-000 h-[2rem]">
<div id="cpm-dynamic-icon-container" class="group/icon min-w-4 min-h-4 flex items-center justify-center text-text-300 shrink-0 group-hover:text-text-100">
<div id="cpm-icon-mode-off"><svg class="cpm-svg-icon" style="width:16px; height:16px; stroke-width:1.8;"><use href="#cpm-icon-pdf-mode-off"></use></svg></div>
<div id="cpm-icon-mode-on" class="hidden"><svg class="cpm-svg-icon" style="width:16px; height:16px; stroke-width:1.8;"><use href="#cpm-icon-pdf-mode-on"></use></svg></div>
</div>
<div class="flex flex-col flex-1 min-w-0"><p class="text-[0.9375rem] text-text-300 group-hover:text-text-100">Force PDF Deep Analysis</p></div>
<div class="flex items-center justify-center text-text-400" title="此功能为普通账户设计,可强制使用高级解析路径。Pro/Team账户原生支持,此开关对其无效。"><svg class="cpm-svg-icon" style="width:16px; height:16px; stroke-width:1.5;"><use href="#cpm-icon-help"></use></svg></div>
<div class="group/switch relative select-none cursor-pointer ml-2">
<input class="peer sr-only" type="checkbox" id="cpm-attachment-mode-toggle-switch">
<div class="border-border-300 rounded-full peer:can-focus peer-disabled:opacity-50 bg-bg-500 transition-colors peer-checked:bg-accent-secondary-100" style="width: 28px; height: 16px;"></div>
<div id="cpm-attachment-mode-toggle-slider" class="absolute start-[2px] top-[2px] rounded-full transition-all group-hover/switch:opacity-80 bg-white transition" style="height: 12px; width: 12px;"></div>
</div>
</button>
</div>
</div>
</div>
</div>
</div>`;
toolbar.insertBefore(wrapperDiv, emptyArea);
this.setupEventListeners();
},
updateSubPanelIcon(isForceMode) {
document.getElementById('cpm-icon-mode-off')?.classList.toggle('hidden', isForceMode);
document.getElementById('cpm-icon-mode-on')?.classList.toggle('hidden', !isForceMode);
},
setupEventListeners() {
const triggerBtn = document.getElementById('cpm-attachment-power-btn');
const menu = document.getElementById('cpm-attachment-power-menu');
const toggleSwitch = document.getElementById('cpm-attachment-mode-toggle-switch');
if (!triggerBtn || !menu || !toggleSwitch) return;
const isInitialForceMode = (this.state.forceUploadMode === 'force');
toggleSwitch.checked = isInitialForceMode;
this.updateSubPanelIcon(isInitialForceMode);
const slider = document.getElementById('cpm-attachment-mode-toggle-slider');
if (slider) {
slider.style.transform = isInitialForceMode ? 'translateX(12px)' : '';
}
triggerBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('hidden'); });
const buttonInsideMenu = menu.querySelector('button.group');
buttonInsideMenu.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
toggleSwitch.checked = !toggleSwitch.checked;
const isForceMode = toggleSwitch.checked;
this.state.forceUploadMode = isForceMode ? 'force' : 'default';
this.updateSubPanelIcon(isForceMode);
const slider = document.getElementById('cpm-attachment-mode-toggle-slider');
if (slider) {
slider.style.transform = isForceMode ? 'translateX(12px)' : '';
}
console.log(LOG_PREFIX, `强制PDF深度解析模式已: ${isForceMode ? '开启' : '关闭'}`);
});
document.addEventListener('click', (e) => {
if (!menu.classList.contains('hidden') && !triggerBtn.contains(e.target) && !menu.contains(e.target)) {
menu.classList.add('hidden');
}
});
},
getOrCreatePreviewPanel() {
let panel = document.getElementById(Config.ATTACHMENT_PANEL_ID);
if (!panel) {
panel = document.createElement('div');
panel.id = Config.ATTACHMENT_PANEL_ID;
panel.innerHTML = `
<div class="cpm-attachment-panel-header">
<span>PDF深度解析暂存区</span>
<button class="cpm-icon-btn cpm-attachment-panel-close-btn" title="关闭并清空所有暂存文件">
<svg class="cpm-svg-icon" style="width:16px; height:16px;"><use href="#cpm-icon-close"></use></svg>
</button>
</div>
<div class="cpm-attachment-panel-content"></div>`;
document.body.appendChild(panel);
panel.querySelector('.cpm-attachment-panel-close-btn').onclick = () => this.clearAndHidePanel();
panel.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('.cpm-preview-delete-btn');
if (!deleteBtn) return;
e.preventDefault(); e.stopPropagation();
const uuidToDelete = deleteBtn.dataset.uuid;
this.removeStagedFile(uuidToDelete);
});
this.panelObserver = new MutationObserver(() => {
if (!document.getElementById(Config.ATTACHMENT_PANEL_ID)) {
this.clearStagedFiles();
this.panelObserver.disconnect();
this.panelObserver = null;
console.log(LOG_PREFIX, "暂存面板已从DOM移除,自动清空暂存文件。");
}
});
this.panelObserver.observe(document.body, { childList: true });
}
return panel;
},
showPreviewPanel() {
const panel = this.getOrCreatePreviewPanel();
void panel.offsetWidth;
panel.classList.add('visible');
},
hidePreviewPanel() {
const panel = document.getElementById(Config.ATTACHMENT_PANEL_ID);
if (panel) {
panel.classList.remove('visible');
const transitionEndHandler = () => {
if (!panel.classList.contains('visible')) {
panel.remove();
}
panel.removeEventListener('transitionend', transitionEndHandler);
};
panel.addEventListener('transitionend', transitionEndHandler);
}
},
addFileToPanel(fileInfo) {
this.showPreviewPanel();
const content = this.getOrCreatePreviewPanel().querySelector('.cpm-attachment-panel-content');
if (!content) return;
const previewUrl = `/api/${fileInfo.org_uuid}/files/${fileInfo.uuid}/document_pdf/${encodeURIComponent(fileInfo.fileName)}`;
const wrapper = document.createElement('div');
wrapper.className = 'cpm-preview-thumbnail-wrapper';
wrapper.id = `thumbnail-wrapper-${fileInfo.uuid}`;
wrapper.innerHTML = `
<button class="cpm-preview-delete-btn" data-uuid="${fileInfo.uuid}" title="移除文件">
<svg class="cpm-svg-icon" style="width:12px; height:12px;"><use href="#cpm-icon-close"></use></svg>
</button>
<a href="${previewUrl}" target="_blank" rel="noopener noreferrer" class="cpm-preview-thumbnail-link" title="点击预览: ${fileInfo.fileName}">
<img src="${fileInfo.thumbnailUrl}" alt="${fileInfo.fileName}">
<div class="cpm-preview-thumbnail-overlay">
<p class="cpm-preview-thumbnail-name">${fileInfo.fileName}</p>
</div>
</a>`;
content.appendChild(wrapper);
},
clearStagedFiles() {
if (this.state.stagedAttachments.length > 0) {
console.log(LOG_PREFIX, `正在清空 ${this.state.stagedAttachments.length} 个暂存文件。`);
this.state.stagedAttachments = [];
}
},
clearAndHidePanel() {
this.clearStagedFiles();
this.hidePreviewPanel();
},
removeStagedFile(uuid) {
const index = this.state.stagedAttachments.findIndex(f => f.uuid === uuid);
if (index > -1) {
const fileName = this.state.stagedAttachments[index].fileName;
this.state.stagedAttachments.splice(index, 1);
console.log(LOG_PREFIX, `文件已从暂存区移除: ${fileName}`);
document.getElementById(`thumbnail-wrapper-${uuid}`)?.remove();
if (this.state.stagedAttachments.length === 0) {
this.hidePreviewPanel();
}
}
},
schedulePanelClosure(delay = 3000) {
setTimeout(() => {
const panel = document.getElementById(Config.ATTACHMENT_PANEL_ID);
if (panel) this.hidePreviewPanel();
}, delay);
},
shouldForceUpload(fileName) {
if (!fileName || typeof fileName !== 'string') return false;
const ext = ('.' + fileName.split('.').pop()).toLowerCase();
return Config.FORCE_UPLOAD_TARGET_EXTENSIONS.includes(ext) && this.state.forceUploadMode === 'force';
},
cleanup() {
document.querySelector('#cpm-attachment-power-btn')?.closest('div.relative.shrink-0').remove();
this.hidePreviewPanel();
if (this.panelObserver) {
this.panelObserver.disconnect();
this.panelObserver = null;
}
}
};
// =========================================================================
// 9. 核心拦截与启动模块
// =========================================================================
const App = {
lastUrl: '',
observer: null,
init() {
ThemeManager.init();
this.installFetchInterceptor();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.startObserver());
} else {
this.startObserver();
}
},
installFetchInterceptor() {
const originalFetch = window.fetch;
window.fetch = async function(...args) {
let url = args[0] instanceof Request ? args[0].url : String(args[0]);
let options = args[1] || {};
if (url.includes('/convert_document') && options.body instanceof FormData) {
const file = Array.from(options.body.values()).find(v => v instanceof File);
if (file && AttachmentEnhancer.shouldForceUpload(file.name)) {
console.groupCollapsed(`%c${LOG_PREFIX} [劫持] 强制PDF深度解析...`, 'color: #ef4444; font-weight: bold;');
const orgUuidMatch = url.match(/\/api\/organizations\/(.*?)\/convert_document/);
if (orgUuidMatch) {
const org_uuid = orgUuidMatch[1];
const uploadUrl = `/api/${org_uuid}/upload`;
originalFetch(uploadUrl, options)
.then(res => res.ok ? res.json() : Promise.reject(`后台上传失败: ${res.statusText}`))
.then(uploadResult => {
if (uploadResult.file_uuid && uploadResult.thumbnail_asset?.url) {
const fileInfo = {
uuid: uploadResult.file_uuid,
fileName: uploadResult.file_name,
org_uuid: org_uuid,
thumbnailUrl: uploadResult.thumbnail_asset.url
};
AttachmentEnhancer.state.stagedAttachments.push(fileInfo);
AttachmentEnhancer.addFileToPanel(fileInfo);
console.log('后台 /upload 强制上传成功并已暂存:', fileInfo.fileName);
}
}).catch(error => console.error(`${LOG_PREFIX} 后台 /upload 任务失败:`, error))
.finally(() => console.groupEnd());
} else {
console.error(`${LOG_PREFIX} 无法从URL中提取组织UUID。`);
console.groupEnd();
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200, statusText: "OK (Handled by Enhancer)" }));
}
}
if (url.includes('/completion') && (AttachmentEnhancer.state.stagedAttachments.length > 0 || NavigatorEnhancer.state.selectedParentMessageUUID)) {
console.groupCollapsed(`%c${LOG_PREFIX} 请求注入: 正在处理/completion...`, 'color: #8b5cf6; font-weight: bold;');
if (options.body && typeof options.body === 'string') {
try {
const payload = JSON.parse(options.body);
if (AttachmentEnhancer.state.stagedAttachments.length > 0) {
console.log(`执行附件注入... (${AttachmentEnhancer.state.stagedAttachments.length}个文件)`);
const hijackedFileNames = AttachmentEnhancer.state.stagedAttachments.map(att => att.fileName);
if (payload.attachments) { payload.attachments = payload.attachments.filter(att => !hijackedFileNames.includes(att.file_name)); }
const fileUuidsToInject = AttachmentEnhancer.state.stagedAttachments.map(att => att.uuid);
if (!payload.files) payload.files = [];
fileUuidsToInject.forEach(uuid => { if (!payload.files.includes(uuid)) payload.files.push(uuid); });
AttachmentEnhancer.clearStagedFiles();
AttachmentEnhancer.schedulePanelClosure();
console.log("附件注入完成,暂存区已清空。");
}
if (NavigatorEnhancer.state.selectedParentMessageUUID) {
console.log("执行分支注入...");
payload.parent_message_uuid = NavigatorEnhancer.state.selectedParentMessageUUID;
NavigatorEnhancer.state.selectedParentMessageUUID = null;
setTimeout(() => NavigatorEnhancer.updateStatusIndicator(), 0);
console.log("分支注入完成。");
}
options.body = JSON.stringify(payload);
} catch (e) { console.error(LOG_PREFIX, "修改/completion请求体失败:", e);
} finally { console.groupEnd(); }
}
}
// 执行原始请求
const response = originalFetch.apply(this, args);
// 拦截 /completion 和 /retry_completion 的响应
if (url.includes('/completion') || url.includes('/retry_completion')) {
return response.then(async (originalResponse) => {
try {
// 检查响应是否成功
if (originalResponse.ok) {
console.log(`%c${LOG_PREFIX} 响应拦截: ${url.includes('/retry_completion') ? '/retry_completion' : '/completion'} 请求成功完成`, 'color: #10b981; font-weight: bold;');
// 延迟清除对话树缓存,确保服务器端数据已更新完成
setTimeout(() => {
ClaudeAPI.isInitialized = false;
ClaudeAPI.conversationTree = null;
ClaudeAPI.currentLinearBranch = null;
console.log(`%c${LOG_PREFIX} 已清除对话树缓存,下次导航器访问时将重新获取最新数据`, 'color: #10b981;');
}, 500); // 延迟500ms,等待服务器处理完成
}
} catch (error) {
console.warn(`${LOG_PREFIX} 响应处理时出错:`, error);
}
return originalResponse;
}).catch((error) => {
console.error(`${LOG_PREFIX} 请求失败:`, error);
throw error;
});
}
return response;
};
},
startObserver() {
this.observer = new MutationObserver(() => this.onPageChange());
this.observer.observe(document.body, { childList: true, subtree: true });
this.onPageChange();
},
cleanup() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
ThemeManager.cleanup();
},
onPageChange() {
const currentUrl = location.href;
// 检测对话切换
ClaudeAPI.checkConversationChange();
if (currentUrl === this.lastUrl && document.getElementById('cpm-manager-button')) {
if(document.querySelector(Config.TOOLBAR_SELECTOR) && !document.getElementById('cpm-branch-btn')) {
this.setupEnhancers(currentUrl);
}
return;
}
this.lastUrl = currentUrl;
console.log(LOG_PREFIX, "URL变更或初次加载,执行页面设置。");
ManagerUI.init();
this.setupEnhancers(currentUrl);
if (AttachmentEnhancer.state.stagedAttachments.length > 0) {
AttachmentEnhancer.showPreviewPanel();
} else {
AttachmentEnhancer.hidePreviewPanel();
}
},
setupEnhancers(currentUrl) {
const toolbar = document.querySelector(Config.TOOLBAR_SELECTOR);
if (toolbar) {
NavigatorEnhancer.init();
AttachmentEnhancer.init();
LinearNavEnhancer.init();
NavigatorEnhancer.updateState(currentUrl);
LinearNavEnhancer.updateState(currentUrl);
} else {
NavigatorEnhancer.cleanup();
AttachmentEnhancer.cleanup();
LinearNavEnhancer.cleanup();
}
}
};
// =========================================================================
// 10. CSS 样式 (全部整合)
// =========================================================================
GM_addStyle(`
/* --- THEME VARIABLES --- */
body[cpm-theme='light'] {
--cpm-bg-000: 0 0% 100%; --cpm-bg-100: 48 33.3% 97.1%; --cpm-bg-200: 53 28.6% 94.5%; --cpm-bg-300: 48 25% 92.2%; --cpm-bg-400: 50 20.7% 88.6%; --cpm-bg-500: 50 20.7% 88.6%;
--cpm-text-000: 60 2.6% 7.6%; --cpm-text-100: 60 2.6% 7.6%; --cpm-text-200: 60 2.5% 23.3%; --cpm-text-300: 60 2.5% 23.3%; --cpm-text-400: 51 3.1% 43.7%; --cpm-text-500: 51 3.1% 43.7%;
--cpm-border-100: 30 3.3% 11.8%; --cpm-border-200: 30 3.3% 11.8%; --cpm-border-300: 45 8.3% 84.1%; --cpm-border-400: 30 3.3% 11.8%;
--cpm-accent-brand: 15 63.1% 59.6%; --cpm-accent-secondary-100: 210 70.9% 51.6%; --cpm-accent-pro-100: 251 40% 45.1%;
--cpm-danger-000: 0 72.2% 50.6%; --cpm-danger-100: 0 58.6% 34.1%; --cpm-success-000: 145 58% 34%; --cpm-oncolor-100: 0 0% 100%;
--cpm-highlight-orange: 31 56% 61%; --cpm-brand-orange-base: 19 58% 55%; --cpm-always-black: 0 0% 0%;
--cpm-sender-you-color: #15803d; --cpm-sender-claude-color: #1d4ed8;
--cpm-branch-hover-bg: rgba(93, 93, 255, 0.2); --cpm-branch-selected-bg: #43a047; --cpm-branch-selected-text: white;
--cpm-mode-active: #2563eb; --cpm-mode-inactive: #6b7280;
}
body[cpm-theme='light'] #cpm-back-to-main,
body[cpm-theme='light'] #cpm-batch-export-now-btn {
color: hsl(var(--cpm-text-000)) !important;
}
body[cpm-theme='dark'] {
--cpm-bg-000: 60 2.1% 18.4%; --cpm-bg-100: 60 2.7% 14.5%; --cpm-bg-200: 30 3.3% 11.8%; --cpm-bg-300: 60 2.6% 7.6%; --cpm-bg-400: 60 3.4% 5.7%; --cpm-bg-500: 60 3.4% 5.7%;
--cpm-text-000: 48 33.3% 97.1%; --cpm-text-100: 48 33.3% 97.1%; --cpm-text-200: 50 9% 73.7%; --cpm-text-300: 50 9% 73.7%; --cpm-text-400: 48 4.8% 59.2%; --cpm-text-500: 48 4.8% 59.2%;
--cpm-border-100: 51 16.5% 84.5%; --cpm-border-200: 51 16.5% 84.5%; --cpm-border-300: 51 16.5% 84.5%; --cpm-border-400: 51 16.5% 84.5%;
--cpm-accent-brand: 15 63.1% 59.6%; --cpm-accent-secondary-100: 210 70.9% 51.6%; --cpm-accent-pro-100: 251 40.2% 54.1%;
--cpm-danger-000: 0 73.1% 66.5%; --cpm-danger-100: 0 58.6% 34.1%; --cpm-success-000: 145 63% 52%; --cpm-oncolor-100: 0 0% 100%;
--cpm-highlight-orange: 31 56% 61%; --cpm-brand-orange-base: 19 58% 55%; --cpm-always-black: 0 0% 0%;
--cpm-sender-you-color: #81c784; --cpm-sender-claude-color: #82aaff;
--cpm-branch-hover-bg: rgba(93, 93, 255, 0.4); --cpm-branch-selected-bg: #2a9d8f; --cpm-branch-selected-text: white;
}
/* --- SHARED & BASE --- */
.cpm-svg-icon { width: 1.1em; height: 1.1em; display: inline-block; vertical-align: middle; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#cpm-manager-button { position: fixed; bottom: 18px; right: 18px; z-index: 9998; background-color: hsl(var(--cpm-brand-orange-base)); color: hsl(var(--cpm-oncolor-100)); border: none; border-radius: 8px; padding: 4px 8px; font-size: 16px; font-weight: 600; font-family: sans-serif; cursor: pointer; letter-spacing: 0.2px; box-shadow: 0 4px 12px hsla(var(--cpm-text-000), 0.15); transition: all 0.2s ease-in-out; }
#cpm-manager-button:hover { box-shadow: 0 8px 20px hsla(var(--cpm-text-000), 0.2); transform: scale(1.05) rotate(-1deg); }
#cpm-manager-button:active { box-shadow: 0 2px 5px hsla(var(--cpm-text-000), 0.15); transform: scale(0.98); transition-duration: 0.1s; }
/* --- PANELS & MODALS --- */
.cpm-panel { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 80vw; max-width: 800px; height: 80vh; background-color: hsl(var(--cpm-bg-100)); color: hsl(var(--cpm-text-200)); border: 1px solid hsl(var(--cpm-border-300)); border-radius: 12px; z-index: 9999; box-shadow: 0 10px 25px hsla(var(--cpm-text-000), 0.2); flex-direction: column; font-family: sans-serif; transition: background-color 0.3s, color 0.3s; }
.cpm-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: hsla(var(--cpm-text-000), 0.7); display: flex; justify-content: center; align-items: center; z-index: 10000; }
.cpm-export-modal-content { max-width: 600px; height: auto; max-height: 90vh; }
.cpm-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-bottom: 1px solid hsl(var(--cpm-border-200)); flex-shrink: 0; }
.cpm-header h2 { margin: 0; font-size: 18px; color: hsl(var(--cpm-text-100)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cpm-header-actions { display: flex; align-items: center; gap: 8px; }
.cpm-icon-btn { background: none; border: none; color: hsl(var(--cpm-text-400)); font-size: 1.1em; cursor: pointer; padding: 4px; border-radius: 4px; transition: color 0.2s, background-color 0.2s; line-height: 1; display:flex; align-items:center; justify-content:center; }
.cpm-icon-btn:hover { color: hsl(var(--cpm-text-100)); background-color: hsl(var(--cpm-bg-200)); }
/* --- MANAGER UI --- */
.cpm-toolbar { display: flex; flex-wrap: wrap; gap: 15px; padding: 12px 20px; background-color: hsl(var(--cpm-bg-200)); border-bottom: 1px solid hsl(var(--cpm-border-200)); align-items: center; flex-shrink: 0; }
.cpm-toolbar-group { display: flex; align-items: center; gap: 8px; }
.cpm-toolbar input, .cpm-toolbar select { background-color: hsl(var(--cpm-bg-000)); color: hsl(var(--cpm-text-100)); border: 1px solid hsl(var(--cpm-border-300)); border-radius: 4px; padding: 4px 8px; }
.cpm-btn, .cpm-action-btn { background-color: hsl(var(--cpm-bg-400)); color: hsl(var(--cpm-text-100)); border: 1px solid hsl(var(--cpm-border-300)); border-radius: 6px; padding: 4px 10px; cursor: pointer; transition: background-color 0.2s, border-color 0.2s; }
.cpm-btn:hover, .cpm-action-btn:hover { background-color: hsl(var(--cpm-bg-500)); }
.cpm-actions { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px 20px; align-items: center; flex-shrink: 0; }
.cpm-action-btn { padding: 8px 14px; }
.cpm-action-btn:disabled { background-color: hsl(var(--cpm-bg-300)); cursor: not-allowed; opacity: 0.6; }
.cpm-danger-btn { background-color: hsla(var(--cpm-danger-100), 0.8); border-color: hsl(var(--cpm-danger-100)); }
.cpm-danger-btn:hover { background-color: hsl(var(--cpm-danger-100)); }
.cpm-batch-export-btn { background-color: hsl(var(--cpm-bg-300)); border: 1px solid hsl(var(--cpm-border-300)); padding: 8px; border-radius: 6px; }
.cpm-batch-export-btn:hover { background-color: hsl(var(--cpm-bg-400)); border-color: hsl(var(--cpm-accent-secondary-100)); }
.cpm-batch-export-btn svg { width: 20px !important; height: 20px !important; }
#cpm-refresh { margin-left: auto; }
.cpm-list-container { flex-grow: 1; overflow-y: auto; padding: 0 5px 0 20px; border-top: 1px solid hsl(var(--cpm-border-200)); }
.cpm-loading, .cpm-error, .cpm-list-container p { color: hsl(var(--cpm-text-300)); text-align: center; margin-top: 20px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.cpm-convo-list { list-style: none; padding: 0; margin: 0; }
.cpm-convo-list li { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid hsl(var(--cpm-border-200)); transition: background-color 0.2s; }
.cpm-convo-list li:not(.is-editing):hover { background-color: hsl(var(--cpm-bg-200)); }
.cpm-checkbox { margin-right: 15px; width: 16px; height: 16px; cursor: pointer; flex-shrink: 0; }
.cpm-convo-details { display: flex; flex-direction: column; gap: 4px; flex-grow: 1; min-width: 0; }
.cpm-convo-title { font-size: 15px; color: hsl(var(--cpm-text-100)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color 0.3s ease; }
.cpm-star { color: #facc15; margin-right: 5px; }
.cpm-convo-date { font-size: 12px; color: hsl(var(--cpm-text-400)); }
.cpm-convo-actions { display: flex; gap: 5px; padding: 0 10px; }
.cpm-action-save { color: hsl(var(--cpm-success-000)) !important; }
.cpm-action-cancel { color: hsl(var(--cpm-danger-000)) !important; }
.cpm-status-bar { padding: 8px 20px; border-top: 1px solid hsl(var(--cpm-border-200)); font-size: 12px; color: hsl(var(--cpm-text-400)); text-align: right; flex-shrink: 0; transition: color 0.3s; }
.cpm-status-bar.is-error { color: hsl(var(--cpm-danger-000)); }
.cpm-status-bar.is-success { color: hsl(var(--cpm-success-000)); }
.cpm-highlight { color: hsl(var(--cpm-accent-brand)); font-weight: bold; background-color: hsla(var(--cpm-accent-brand), 0.1); }
.cpm-edit-input { width: 100%; background-color: hsl(var(--cpm-bg-200)); border: 1px solid hsl(var(--cpm-border-300)); border-radius: 4px; color: hsl(var(--cpm-text-100)); padding: 4px 8px; font-size: 15px; line-height: 1.5; box-sizing: border-box; }
.cpm-edit-input:focus { outline: none; border-color: hsl(var(--cpm-accent-brand)); }
li.is-editing .cpm-convo-details { padding-top: 2px; padding-bottom: 2px; }
/* --- SETTINGS PANEL --- */
.cpm-settings-content { padding: 20px; overflow-y: auto; background-color: hsl(var(--cpm-bg-000)); flex-grow: 1; }
.cpm-setting-section { margin-bottom: 25px; border-bottom: 1px solid hsl(var(--cpm-border-200)); padding-bottom: 15px; }
.cpm-setting-section:last-of-type { border-bottom: none; }
.cpm-setting-section-title { margin-top: 0; padding-bottom: 15px; color: hsl(var(--cpm-text-100)); font-size: 16px; font-weight: 600; }
.cpm-setting-group { margin-bottom: 15px; }
.cpm-setting-group h4 { color: hsl(var(--cpm-text-300)); font-size: 14px; margin-bottom: 10px; }
.cpm-setting-sub-group { padding-left: 20px; border-left: 2px solid hsl(var(--cpm-bg-200)); margin-top: 10px; }
.cpm-setting-item { display: flex; align-items: center; gap: 15px; margin-bottom: 12px; }
.cpm-setting-item label { color: hsl(var(--cpm-text-200)); cursor: pointer; }
.cpm-settings-label { width: 150px; text-align: right; flex-shrink: 0; }
.cpm-setting-item input[type="text"], .cpm-setting-item input[type="number"], .cpm-setting-item select { background-color: hsl(var(--cpm-bg-100)); border: 1px solid hsl(var(--cpm-border-300)); color: hsl(var(--cpm-text-100)); border-radius: 4px; padding: 8px; flex-grow: 1; }
.cpm-setting-item input[type="checkbox"] { width: 16px; height: 16px; }
.cpm-setting-item.disabled { opacity: 0.5; }
.cpm-setting-item.disabled label { cursor: not-allowed; }
.cpm-settings-buttons { display: flex; justify-content: center; gap: 20px; margin-top: 30px; }
.cpm-settings-buttons .cpm-btn { padding: 10px 20px; color: hsl(var(--cpm-oncolor-100)); border: none; border-radius: 6px; cursor: pointer; }
#cpm-back-to-main { background-color: hsl(var(--cpm-bg-400)); }
#cpm-save-settings-button, #cpm-export-now-btn { background-color: hsl(var(--cpm-accent-secondary-100)); }
#cpm-export-now-btn:disabled { background-color: hsl(var(--cpm-bg-300)); cursor: not-allowed; }
/* --- TREE VIEW --- */
.cpm-tree-panel-override { width: 90vw; max-width: 1200px; height: 90vh; }
.cpm-navigator-panel-override { width: 90vw; max-width: 1200px; height: 90vh; }
.cpm-tree-container { flex-grow: 1; overflow-y: auto; overflow-x: auto; padding: 20px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background-color: hsl(var(--cpm-bg-200)); }
.cpm-tree-node { margin-bottom: 10px; border-radius: 6px; min-width: fit-content; }
.cpm-tree-node-header { margin: 0 0 5px 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: nowrap; padding: 4px; white-space: nowrap; }
.cpm-tree-node-id { color: hsl(var(--cpm-text-400)); font-size: 12px; flex-shrink: 0; }
.cpm-tree-node-sender { font-weight: bold; flex-shrink: 0; }
.sender-you { color: var(--cpm-sender-you-color); }
.sender-claude { color: var(--cpm-sender-claude-color); }
.cpm-tree-node-preview { color: hsl(var(--cpm-text-200)); white-space: nowrap; }
.cpm-tree-attachments { color: hsl(var(--cpm-text-300)); font-size: 12px; padding-left: 20px; }
.cpm-tree-attachments ul { list-style: none; padding-left: 10px; margin: 5px 0 0 0; }
.cpm-tree-attachments li { margin-bottom: 4px; }
.cpm-attachment-source { color: hsl(var(--cpm-accent-pro-100)); margin: 0 5px; font-style: italic; }
.cpm-attachment-details { color: hsl(var(--cpm-text-400)); }
.cpm-attachment-url { color: hsl(var(--cpm-accent-secondary-100)); text-decoration: none; }
.cpm-attachment-url:hover { text-decoration: underline; }
/* --- DIRTY DATA NODE STYLES --- */
.cpm-dirty-node .cpm-tree-node-id { color: hsl(var(--cpm-danger-000)) !important; }
.cpm-dirty-node .cpm-tree-node-sender { color: hsl(var(--cpm-danger-000)) !important; }
/* --- ENHANCER-SPECIFIC STYLES --- */
#cpm-branch-status-indicator { background-color: var(--cpm-branch-selected-bg); color: var(--cpm-branch-selected-text); padding: 2px 8px; font-size: 12px; border-radius: 12px; margin-left: 8px; font-weight: 500; animation: cpm-fadeIn 0.3s ease; }
@keyframes cpm-fadeIn { from { opacity: 0; } to { opacity: 1; } }
#cpm-branch-from-root-btn { border: 1px dashed hsl(var(--cpm-border-300)); padding: 10px; margin-bottom: 20px; text-align: center; font-weight: bold; color: hsl(var(--cpm-text-200)); border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.cpm-node-clickable { cursor: pointer; transition: background-color 0.2s; }
.cpm-node-clickable:hover, #cpm-branch-from-root-btn:hover { background-color: var(--cpm-branch-hover-bg); }
.cpm-node-selected, #cpm-branch-from-root-btn.cpm-node-selected { background-color: var(--cpm-branch-selected-bg) !important; color: var(--cpm-branch-selected-text) !important; }
.cpm-node-selected .cpm-tree-node-sender, .cpm-node-selected .cpm-tree-node-preview, .cpm-node-selected .cpm-tree-node-id { color: var(--cpm-branch-selected-text) !important; }
/* --- MODE SELECTOR --- */
.cpm-mode-selector { display: flex; gap: 8px; padding: 12px 20px; border-bottom: 1px solid hsl(var(--cpm-border-200)); }
.cpm-mode-btn { padding: 8px 16px; border: 1px solid hsl(var(--cpm-border-300)); background: transparent; color: var(--cpm-mode-inactive); border-radius: 6px; cursor: pointer; transition: all 0.2s; font-size: 14px; font-weight: 500; }
.cpm-mode-btn:hover { background-color: hsl(var(--cpm-bg-200)); }
.cpm-mode-btn.active { background-color: var(--cpm-mode-active); color: white; border-color: var(--cpm-mode-active); }
/* --- 高亮动画 --- */
.highlight-pulse { animation: cpm-highlight-pulse 3s ease-out; }
@keyframes cpm-highlight-pulse {
0%, 100% { background-color: rgba(255, 243, 205, 0); }
20% { background-color: rgba(255, 243, 205, 1); }
}
#cpm-attachment-power-menu .bg-bg-000 { background-color: hsl(var(--cpm-bg-000)); }
#cpm-attachment-power-menu .text-text-200 { color: hsl(var(--cpm-text-200)); }
#cpm-attachment-power-menu .text-text-300 { color: hsl(var(--cpm-text-300)); }
#cpm-attachment-power-menu .hover\\:bg-bg-200\\/50:hover { background-color: hsl(var(--cpm-bg-200) / 0.5); }
#cpm-attachment-power-menu .hover\\:text-text-000:hover { color: hsl(var(--cpm-text-000)); }
#cpm-attachment-power-menu .group-hover\\:text-text-100:hover { color: hsl(var(--cpm-text-100)); }
#cpm-attachment-power-menu .bg-bg-500 { background-color: hsl(var(--cpm-bg-500)); }
#cpm-attachment-mode-toggle-switch:checked + div { background-color: hsl(var(--cpm-accent-secondary-100)) !important; }
/* --- ATTACHMENT PREVIEW PANEL --- */
#cpm-attachment-preview-panel {
position: fixed; right: 20px; bottom: 80px; width: 320px; max-height: 480px;
background-color: hsl(var(--cpm-bg-100));
border: 0.5px solid hsl(var(--cpm-border-300));
border-radius: 12px; box-shadow: 0 10px 25px -5px hsla(var(--cpm-always-black), 0.1), 0 8px 10px -6px hsla(var(--cpm-always-black), 0.1);
z-index: 9999; display: flex; flex-direction: column; overflow: hidden;
opacity: 0; transform: translateY(20px); transition: opacity 0.4s ease-out, transform 0.4s ease-out;
pointer-events: none;
}
#cpm-attachment-preview-panel.visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
.cpm-attachment-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 8px 8px 12px; font-weight: 600; font-size: 14px; color: hsl(var(--cpm-text-300));
border-bottom: 0.5px solid hsl(var(--cpm-border-200)); flex-shrink: 0;
}
.cpm-attachment-panel-content { padding: 12px; display: flex; flex-wrap: wrap; gap: 12px; overflow-y: auto; justify-content: center; }
.cpm-preview-thumbnail-wrapper {
position: relative;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
border-radius: 8px;
box-shadow: 0 1px 3px 0 hsla(var(--cpm-always-black), 0.08);
}
.cpm-preview-thumbnail-wrapper:hover {
transform: scale(1.04);
z-index: 10;
box-shadow: 0 8px 16px hsla(var(--cpm-always-black), 0.15);
}
.cpm-preview-thumbnail-link {
display: block;
width: 112px;
height: 160px;
border-radius: 8px;
overflow: hidden;
border: 0.5px solid hsla(var(--cpm-border-300), 0.5);
text-decoration: none;
position: relative;
background-color: hsl(var(--cpm-bg-300));
}
.cpm-preview-thumbnail-link img { width: 100%; height: 100%; object-fit: cover; }
.cpm-preview-thumbnail-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, hsla(var(--cpm-always-black), 0.8), transparent); padding: 12px 6px 6px; text-align: center; }
.cpm-preview-thumbnail-name { color: white; font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cpm-preview-delete-btn {
position: absolute;
top: -8px;
left: -8px;
width: 20px;
height: 20px;
background-color: hsla(var(--cpm-bg-000), 0.9);
color: hsl(var(--cpm-text-400));
border: 0.5px solid hsla(var(--cpm-border-200), 0.25);
border-radius: 50%;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.2s ease, transform 0.2s ease, background-color 0.2s ease, color 0.2s ease;
z-index: 20;
}
.cpm-preview-thumbnail-wrapper:hover .cpm-preview-delete-btn {
opacity: 1;
transform: scale(1);
}
.cpm-preview-delete-btn:hover {
background-color: hsla(var(--cpm-bg-200), 0.95);
color: hsl(var(--cpm-text-100));
}
.cpm-preview-delete-btn svg {
width: 12px;
height: 12px;
}
/* --- LINEAR NAVIGATION UI --- */
/* 基础容器 */
#cpm-ln-nav {
position: fixed;
top: 120px;
right: 20px;
width: auto;
min-width: 80px;
max-width: 210px;
z-index: 2147483647 !important;
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
pointer-events: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
background-color: hsl(var(--cpm-bg-100));
border: 1px solid hsl(var(--cpm-border-300));
border-radius: 8px;
box-shadow: 0 10px 25px hsla(var(--cpm-text-000), 0.15);
}
#cpm-ln-nav.visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
#cpm-ln-nav * { user-select: none; }
/* 头部区域 */
.cpm-ln-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
margin-bottom: 4px;
cursor: move;
min-width: 100px;
border-bottom: 1px solid hsl(var(--cpm-border-200));
}
.cpm-ln-title {
font: 600 11px/1 inherit;
color: hsl(var(--cpm-text-200));
display: flex;
align-items: center;
gap: 3px;
}
.cpm-ln-title svg { width: 12px; height: 12px; }
.cpm-ln-close, .cpm-ln-refresh {
width: 22px;
height: 22px;
font-size: 14px;
background: none;
border: none;
color: hsl(var(--cpm-text-400));
cursor: pointer;
padding: 3px;
border-radius: 4px;
transition: color 0.2s, background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.cpm-ln-close:hover, .cpm-ln-refresh:hover {
color: hsl(var(--cpm-text-100));
background-color: hsl(var(--cpm-bg-200));
}
/* 列表区域 */
.cpm-ln-list {
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
}
.cpm-ln-list::-webkit-scrollbar { width: 3px; }
.cpm-ln-list::-webkit-scrollbar-thumb {
background: hsla(var(--cpm-text-400), 0.3);
border-radius: 2px;
}
.cpm-ln-list::-webkit-scrollbar-thumb:hover {
background: hsla(var(--cpm-text-400), 0.5);
}
/* 列表项 */
.cpm-ln-item {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
font-size: 12px;
min-height: 24px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: auto;
min-width: 60px;
max-width: 190px;
background-color: hsl(var(--cpm-bg-200));
}
.cpm-ln-item:hover {
transform: translateX(2px);
box-shadow: 0 2px 6px hsla(var(--cpm-always-black), 0.12);
background-color: hsl(var(--cpm-bg-300));
}
/* 用户/助手样式 */
.cpm-ln-item.user {
color: hsl(var(--cpm-accent-secondary-100));
border-left: 3px solid hsl(var(--cpm-accent-secondary-100));
font-weight: 500;
}
.cpm-ln-item.assistant {
color: hsl(var(--cpm-accent-brand));
border-left: 3px solid hsl(var(--cpm-accent-brand));
font-weight: 500;
}
.cpm-ln-item.active {
border: 2px solid hsl(var(--cpm-accent-pro-100));
box-shadow: 0 2px 8px hsla(var(--cpm-accent-pro-100), 0.2);
background-color: hsl(var(--cpm-bg-400));
}
.cpm-ln-number {
margin-right: 4px;
font: 600 11px/1 inherit;
color: hsl(var(--cpm-text-400));
}
.cpm-ln-empty {
padding: 10px;
text-align: center;
color: hsl(var(--cpm-text-400));
font-size: 11px;
min-height: 20px;
}
/* 上下置顶置底按钮 */
.cpm-ln-footer {
margin-top: 8px;
display: flex;
gap: 4px;
padding: 8px;
border-top: 1px solid hsl(var(--cpm-border-200));
}
/* 导航按钮统一样式(四个按钮共用) */
.cpm-ln-nav-btn {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
color: hsl(var(--cpm-text-300));
background: hsl(var(--cpm-bg-200));
border: 1px solid hsl(var(--cpm-border-300));
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
line-height: 1;
}
.cpm-ln-nav-btn .cpm-svg-icon {
width: 14px;
height: 14px;
}
.cpm-ln-nav-btn:hover {
color: hsl(var(--cpm-text-100));
border-color: hsl(var(--cpm-accent-secondary-100));
background-color: hsl(var(--cpm-bg-300));
}
`);
// =========================================================================
// 11. 辅助工具 & 启动脚本
// =========================================================================
App.init();
})(unsafeWindow);