10238 lines
392 KiB
JavaScript
10238 lines
392 KiB
JavaScript
// === CC-Web Frontend ===
|
||
(function () {
|
||
'use strict';
|
||
|
||
const ASSET_VERSION = '20260629-ccweb-prompt-dark-theme';
|
||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||
const RENDER_DEBOUNCE = 100;
|
||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||
const DIVIDER_TIME_STORAGE_KEY = 'cc-web-show-divider-time';
|
||
const CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY = 'cc-web-ccweb-prompt-view-mode';
|
||
const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects';
|
||
const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies';
|
||
const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500;
|
||
const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn';
|
||
const ASSISTANT_BRANCH_BUTTON_CLASS = 'msg-branch-btn';
|
||
const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus';
|
||
const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72;
|
||
const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [
|
||
`.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`,
|
||
`.${ASSISTANT_BRANCH_BUTTON_CLASS}`,
|
||
'.msg-action-row',
|
||
'.msg-tools',
|
||
'.tool-call',
|
||
'.tool-group',
|
||
'.msg-attachments',
|
||
'.msg-attachment-card',
|
||
'.cross-conversation-meta',
|
||
'.agent-message-divider',
|
||
].join(',');
|
||
const ASSISTANT_LAST_SECTION_SCOPE_SELECTOR = [
|
||
'p',
|
||
'li',
|
||
'h1',
|
||
'h2',
|
||
'h3',
|
||
'h4',
|
||
'h5',
|
||
'h6',
|
||
'td',
|
||
'th',
|
||
'pre',
|
||
'code',
|
||
'.msg-text',
|
||
].join(',');
|
||
|
||
const SLASH_COMMANDS = [
|
||
{ cmd: '/clear', desc: '清除当前会话' },
|
||
{ cmd: '/model', desc: '查看/切换模型' },
|
||
{ cmd: '/mode', desc: '查看/切换权限模式' },
|
||
{ cmd: '/cost', desc: '查看会话费用' },
|
||
{ cmd: '/compact', desc: '压缩上下文' },
|
||
{ cmd: '/goal', desc: '设置/查看 Codex App 持久目标' },
|
||
{ cmd: '/init', desc: '生成/更新 Agent 指南文件' },
|
||
{ cmd: '/help', desc: '显示帮助' },
|
||
];
|
||
|
||
const MODE_LABELS = {
|
||
default: '默认',
|
||
plan: 'Plan',
|
||
yolo: 'YOLO',
|
||
};
|
||
|
||
const AGENT_LABELS = {
|
||
claude: 'Claude',
|
||
codex: 'Codex',
|
||
codexapp: 'Codex App',
|
||
};
|
||
|
||
const DEFAULT_AGENT = 'claude';
|
||
const SESSION_CACHE_LIMIT = 4;
|
||
const SESSION_CACHE_MAX_WEIGHT = 1_500_000;
|
||
const SIDEBAR_SWIPE_TRIGGER = 72;
|
||
const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
|
||
const OLD_SESSION_GROUP_INITIAL_VISIBLE = 3;
|
||
const SESSION_GROUP_COMPACT_VISIBLE_LIMIT = 8;
|
||
const OLD_SESSION_COLLAPSE_DAYS = 7;
|
||
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
|
||
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
|
||
|
||
const MODEL_OPTIONS = [
|
||
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
||
{ value: 'sonnet', label: 'Sonnet', desc: '平衡性能,1M 上下文' },
|
||
{ value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' },
|
||
];
|
||
|
||
const DEFAULT_CODEX_MODEL_OPTIONS = [
|
||
{ value: 'gpt-5.4', label: 'GPT-5.4', desc: '当前主力 Codex 模型' },
|
||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: '偏工程执行场景' },
|
||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', desc: '兼容旧路由与旧配置' },
|
||
{ value: 'gpt-5.2', label: 'GPT-5.2', desc: '通用 OpenAI 兼容模型' },
|
||
];
|
||
|
||
const MODE_PICKER_OPTIONS = [
|
||
{ value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' },
|
||
{ value: 'plan', label: 'Plan', desc: '执行前需确认计划' },
|
||
{ value: 'default', label: '默认', desc: '标准权限审批' },
|
||
];
|
||
|
||
const THEME_OPTIONS = [
|
||
{
|
||
value: 'washi',
|
||
label: 'Washi Warm',
|
||
desc: '暖纸色与朱砂点缀,保留当前熟悉的 CC-Web 气质。',
|
||
swatches: ['#faf6f0', '#f2ebe2', '#c0553a', '#5d8a54'],
|
||
},
|
||
{
|
||
value: 'coolvibe',
|
||
label: 'CoolVibe Light',
|
||
desc: '保留 CoolVibe 的青色科技感,但改成更干净的浅色工作台。',
|
||
swatches: ['#f7fbfc', '#eef7f9', '#0891b2', '#ffffff'],
|
||
},
|
||
{
|
||
value: 'editorial',
|
||
label: 'Editorial Sand',
|
||
desc: '更明亮的留白和更克制的棕色强调,像编辑台一样安静。',
|
||
swatches: ['#f6f1e8', '#efe8dc', '#8b5e3c', '#2f4b45'],
|
||
},
|
||
{
|
||
value: 'sage',
|
||
label: 'Sage Console',
|
||
desc: '清透鼠尾草绿与石墨文字,适合长时间工作流。',
|
||
swatches: ['#f5f8f2', '#e6efdf', '#2f6f64', '#557ba3'],
|
||
},
|
||
{
|
||
value: 'ink',
|
||
label: 'Ink Focus',
|
||
desc: '浅灰纸面配靛蓝重点,信息密度更高也更冷静。',
|
||
swatches: ['#f6f7fb', '#e8edf5', '#3f5fb5', '#3f8f73'],
|
||
},
|
||
{
|
||
value: 'dawn',
|
||
label: 'Dawn Studio',
|
||
desc: '晨光米白配珊瑚红,保留温度但比暖纸更轻。',
|
||
swatches: ['#fff7f2', '#f3e8e1', '#b5524d', '#4f8a6b'],
|
||
},
|
||
{
|
||
value: 'carbon',
|
||
label: 'Carbon Mint',
|
||
desc: '石墨黑底配薄荷绿重点,夜间使用更稳。',
|
||
swatches: ['#0f1314', '#202829', '#67d8b2', '#9fb7ff'],
|
||
},
|
||
{
|
||
value: 'nocturne',
|
||
label: 'Nocturne Teal',
|
||
desc: '深海青黑配电光蓝,适合高专注会话。',
|
||
swatches: ['#081417', '#142b31', '#5ecdf5', '#f0c36a'],
|
||
},
|
||
{
|
||
value: 'cinder',
|
||
label: 'Cinder Rose',
|
||
desc: '炭黑底配低饱和玫瑰色,暗色里保留一点温度。',
|
||
swatches: ['#151112', '#2a2022', '#e68193', '#67c587'],
|
||
},
|
||
];
|
||
|
||
// --- State ---
|
||
let ws = null;
|
||
let wsAuthenticated = false;
|
||
let authToken = localStorage.getItem('cc-web-token');
|
||
let currentSessionId = null;
|
||
let sessions = [];
|
||
let sessionCache = new Map();
|
||
let isGenerating = false;
|
||
let reconnectAttempts = 0;
|
||
let reconnectTimer = null;
|
||
let isPageUnloading = false;
|
||
let pendingText = '';
|
||
let renderTimer = null;
|
||
let generatingSessionId = null;
|
||
let activeToolCalls = new Map();
|
||
let activeTodoCallTargets = new Map();
|
||
let closedCollabAgentIds = new Set();
|
||
let toolDomSeq = 0;
|
||
let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录)
|
||
let hasGrouped = false; // 本次输出是否已触发过折叠
|
||
let cmdMenuIndex = -1;
|
||
let currentMode = 'yolo';
|
||
let currentModel = 'opus';
|
||
let currentAgent = AGENT_LABELS[localStorage.getItem('cc-web-agent')] ? localStorage.getItem('cc-web-agent') : DEFAULT_AGENT;
|
||
let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi');
|
||
let showAgentDividerTime = localStorage.getItem(DIVIDER_TIME_STORAGE_KEY) !== '0';
|
||
let codexConfigCache = null;
|
||
let loadedHistorySessionId = null;
|
||
let activeSessionLoad = null;
|
||
let sessionLoadOverlayTimer = null;
|
||
let sidebarSwipe = null;
|
||
let activeComposerToken = null;
|
||
let composerSuggestionTimer = null;
|
||
let composerRequestSeq = 0;
|
||
let latestComposerRequestId = '';
|
||
let pendingAttachments = [];
|
||
let uploadingAttachments = [];
|
||
let attachmentPreviewModal = null;
|
||
const attachmentPreviewCache = new Map();
|
||
let loginPasswordValue = ''; // store login password for force-change flow
|
||
let currentCwd = null;
|
||
let currentSessionMessageCount = 0;
|
||
let currentSessionRunning = false;
|
||
let fileBrowserState = null;
|
||
let directoryPickerState = null;
|
||
let codexAppUserInputModal = null;
|
||
let codexAppApprovalModal = null;
|
||
let pendingNewSessionRequest = null;
|
||
let pendingSessionSwitchRequest = null;
|
||
let sessionSwitchRequestSeq = 0;
|
||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||
let pendingInitialSessionLoad = false;
|
||
let initialSessionListHandled = false;
|
||
let noteMode = false;
|
||
let noteDraftSeq = 0;
|
||
let queuedMessageSeq = 0;
|
||
let queuedMessageDrainTimer = null;
|
||
let isReloadingMcp = false;
|
||
const mcpStartupToastKeys = new Map();
|
||
let sessionSearchQuery = '';
|
||
const collapsedProjectKeys = (() => {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY) || '[]');
|
||
return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []);
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
})();
|
||
const collapsedCrossConversationReplyKeys = (() => {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY) || '[]');
|
||
return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []);
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
})();
|
||
const pendingNotesByTarget = new Map();
|
||
const queuedMessagesByTarget = new Map();
|
||
const userMessageIndex = new Map();
|
||
const expandedOldSessionGroups = new Set();
|
||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||
|
||
// --- DOM ---
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const loginOverlay = $('#login-overlay');
|
||
const loginForm = $('#login-form');
|
||
const loginPassword = $('#login-password');
|
||
const loginError = $('#login-error');
|
||
const rememberPw = $('#remember-pw');
|
||
const app = $('#app');
|
||
const sessionLoadingOverlay = $('#session-loading-overlay');
|
||
const sessionLoadingLabel = $('#session-loading-label');
|
||
const sidebar = $('#sidebar');
|
||
const sidebarOverlay = $('#sidebar-overlay');
|
||
const menuBtn = $('#menu-btn');
|
||
const chatMain = document.querySelector('.chat-main');
|
||
const newChatSplit = sidebar.querySelector('.new-chat-split');
|
||
const newChatBtn = $('#new-chat-btn');
|
||
const newChatArrow = $('#new-chat-arrow');
|
||
const newChatDropdown = $('#new-chat-dropdown');
|
||
const importSessionBtn = $('#import-session-btn');
|
||
const sessionSearchInput = $('#session-search-input');
|
||
const sessionSearchClear = $('#session-search-clear');
|
||
const sessionList = $('#session-list');
|
||
const chatTitle = $('#chat-title');
|
||
const chatSessionIdBtn = $('#chat-session-id-btn');
|
||
const chatAgentBtn = $('#chat-agent-btn');
|
||
const chatAgentMenu = $('#chat-agent-menu');
|
||
const chatRuntimeState = $('#chat-runtime-state');
|
||
const chatCwd = $('#chat-cwd');
|
||
const userOutlineBtn = $('#user-outline-btn');
|
||
const userOutlinePanel = $('#user-outline-panel');
|
||
const ccwebPromptOutlineBtn = $('#ccweb-prompt-outline-btn');
|
||
const ccwebPromptOutlinePanel = $('#ccweb-prompt-outline-panel');
|
||
const reloadMcpBtn = $('#reload-mcp-btn');
|
||
const costDisplay = $('#cost-display');
|
||
const attachmentTray = $('#attachment-tray');
|
||
const pendingNotesTray = $('#pending-notes-tray');
|
||
const imageUploadInput = $('#image-upload-input');
|
||
const attachBtn = $('#attach-btn');
|
||
const messagesDiv = $('#messages');
|
||
const msgInput = $('#msg-input');
|
||
const inputWrapper = msgInput.closest('.input-wrapper');
|
||
const noteModeBtn = $('#note-mode-btn');
|
||
const queueSendBtn = $('#queue-send-btn');
|
||
const sendBtn = $('#send-btn');
|
||
const abortBtn = $('#abort-btn');
|
||
const cmdMenu = $('#cmd-menu');
|
||
const modeSelect = $('#mode-select');
|
||
const defaultMsgInputPlaceholder = msgInput.getAttribute('placeholder') || '输入消息… 输入 / 查看指令';
|
||
|
||
// --- Viewport height fix for mobile browsers ---
|
||
function setVH() {
|
||
document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`);
|
||
}
|
||
setVH();
|
||
window.addEventListener('resize', setVH);
|
||
window.addEventListener('orientationchange', () => setTimeout(setVH, 100));
|
||
|
||
function buildWelcomeMarkup(agent) {
|
||
const label = AGENT_LABELS[agent] || AGENT_LABELS.claude;
|
||
return `<div class="welcome-msg"><div class="welcome-icon">✿</div><h3>欢迎使用 CC-Web</h3><p>开始与 ${label} 对话</p></div>`;
|
||
}
|
||
|
||
function normalizeAgent(agent) {
|
||
return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT;
|
||
}
|
||
|
||
function isCodexLikeAgent(agent) {
|
||
const normalized = normalizeAgent(agent);
|
||
return normalized === 'codex' || normalized === 'codexapp';
|
||
}
|
||
|
||
function isCodexAppAgent(agent) {
|
||
return normalizeAgent(agent) === 'codexapp';
|
||
}
|
||
|
||
function getDraftNoteKey(agent = currentAgent) {
|
||
return `draft:${normalizeAgent(agent)}`;
|
||
}
|
||
|
||
function getCurrentNoteKey(agent = currentAgent) {
|
||
return currentSessionId || getDraftNoteKey(agent);
|
||
}
|
||
|
||
function getSessionQueueKey(sessionId) {
|
||
return sessionId ? `session:${sessionId}` : '';
|
||
}
|
||
|
||
function getDraftQueueKey(agent = currentAgent) {
|
||
return `queue:${getDraftNoteKey(agent)}`;
|
||
}
|
||
|
||
function getCurrentQueueKey(agent = currentAgent) {
|
||
return currentSessionId ? getSessionQueueKey(currentSessionId) : getDraftQueueKey(agent);
|
||
}
|
||
|
||
function supportsQueuedSend(agent = currentAgent) {
|
||
return isCodexAppAgent(agent);
|
||
}
|
||
|
||
function getNotesForKey(key, create = true) {
|
||
if (!key) return [];
|
||
if (!pendingNotesByTarget.has(key)) {
|
||
if (!create) return [];
|
||
pendingNotesByTarget.set(key, []);
|
||
}
|
||
return pendingNotesByTarget.get(key);
|
||
}
|
||
|
||
function getCurrentNotes(create = true) {
|
||
return getNotesForKey(getCurrentNoteKey(), create);
|
||
}
|
||
|
||
function cleanupNoteKey(key) {
|
||
const notes = pendingNotesByTarget.get(key);
|
||
if (!notes || notes.length === 0) pendingNotesByTarget.delete(key);
|
||
}
|
||
|
||
function getQueueForKey(key, create = true) {
|
||
if (!key) return [];
|
||
if (!queuedMessagesByTarget.has(key)) {
|
||
if (!create) return [];
|
||
queuedMessagesByTarget.set(key, []);
|
||
}
|
||
return queuedMessagesByTarget.get(key);
|
||
}
|
||
|
||
function getCurrentQueue(create = true) {
|
||
return getQueueForKey(getCurrentQueueKey(), create);
|
||
}
|
||
|
||
function cleanupQueueKey(key) {
|
||
const queue = queuedMessagesByTarget.get(key);
|
||
if (!queue || queue.length === 0) queuedMessagesByTarget.delete(key);
|
||
}
|
||
|
||
function migratePendingNotesToSession(sessionId, agent = currentAgent) {
|
||
if (!sessionId) return;
|
||
const draftKey = getDraftNoteKey(agent);
|
||
const draftNotes = pendingNotesByTarget.get(draftKey);
|
||
if (!draftNotes || draftNotes.length === 0) return;
|
||
const sessionNotes = getNotesForKey(sessionId, true);
|
||
sessionNotes.push(...draftNotes);
|
||
pendingNotesByTarget.delete(draftKey);
|
||
}
|
||
|
||
function migrateQueuedMessagesToSession(sessionId, agent = currentAgent) {
|
||
if (!sessionId) return;
|
||
const draftKey = getDraftQueueKey(agent);
|
||
const draftQueue = queuedMessagesByTarget.get(draftKey);
|
||
if (!draftQueue || draftQueue.length === 0) return;
|
||
const sessionQueue = getQueueForKey(getSessionQueueKey(sessionId), true);
|
||
sessionQueue.push(...draftQueue);
|
||
queuedMessagesByTarget.delete(draftKey);
|
||
}
|
||
|
||
function updateGenerationControls() {
|
||
const noteActive = !!noteMode;
|
||
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive;
|
||
const sendLabel = noteActive ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
|
||
if (sendBtn) {
|
||
sendBtn.classList.toggle('note-send', noteActive);
|
||
sendBtn.title = sendLabel;
|
||
sendBtn.setAttribute('aria-label', sendLabel);
|
||
sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false;
|
||
}
|
||
if (queueSendBtn) {
|
||
const queueAvailable = noteActive && supportsQueuedSend();
|
||
const queueLabel = isGenerating || currentSessionRunning ? '排队发送' : '排队发送(空闲时将立即发送)';
|
||
queueSendBtn.hidden = !queueAvailable;
|
||
queueSendBtn.disabled = !queueAvailable;
|
||
queueSendBtn.title = queueLabel;
|
||
queueSendBtn.setAttribute('aria-label', queueLabel);
|
||
}
|
||
if (abortBtn) {
|
||
abortBtn.hidden = !isGenerating;
|
||
}
|
||
}
|
||
|
||
function updateNoteModeUI() {
|
||
const active = !!noteMode;
|
||
if (noteModeBtn) {
|
||
noteModeBtn.classList.toggle('active', active);
|
||
noteModeBtn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
noteModeBtn.title = active ? '关闭笔记模式' : '笔记模式';
|
||
noteModeBtn.setAttribute('aria-label', active ? '关闭笔记模式' : '笔记模式');
|
||
}
|
||
if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active);
|
||
if (msgInput) {
|
||
msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder;
|
||
}
|
||
if (active) hideCmdMenu();
|
||
updateGenerationControls();
|
||
}
|
||
|
||
function createNoteActionButton(action, label, title = label) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = `note-action ${action}`;
|
||
button.textContent = label;
|
||
button.title = title;
|
||
button.dataset.noteAction = action;
|
||
return button;
|
||
}
|
||
|
||
function createPendingNoteElement(note) {
|
||
const div = document.createElement('div');
|
||
div.className = 'pending-note';
|
||
div.dataset.noteId = note.id;
|
||
|
||
const avatar = document.createElement('div');
|
||
avatar.className = 'note-avatar';
|
||
avatar.textContent = 'N';
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'note-bubble';
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'note-meta';
|
||
meta.textContent = '笔记 · 待发送';
|
||
|
||
const text = document.createElement('div');
|
||
text.className = 'note-text';
|
||
text.textContent = note.text;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'note-actions';
|
||
const editBtn = createNoteActionButton('edit', '修改');
|
||
const deleteBtn = createNoteActionButton('delete', '删除');
|
||
const sendNoteBtn = createNoteActionButton('send', '发送', '发送这条笔记');
|
||
editBtn.addEventListener('click', () => beginEditPendingNote(note.id));
|
||
deleteBtn.addEventListener('click', () => removePendingNote(note.id));
|
||
sendNoteBtn.addEventListener('click', () => sendPendingNote(note.id));
|
||
actions.append(editBtn, deleteBtn);
|
||
if (supportsQueuedSend()) {
|
||
const queueNoteBtn = createNoteActionButton('queue', '排队', '加入自动发送队列');
|
||
queueNoteBtn.addEventListener('click', () => queuePendingNote(note.id));
|
||
actions.appendChild(queueNoteBtn);
|
||
}
|
||
actions.appendChild(sendNoteBtn);
|
||
|
||
bubble.append(meta, text, actions);
|
||
div.append(avatar, bubble);
|
||
return div;
|
||
}
|
||
|
||
function createQueuedMessageElement(message, index, total) {
|
||
const div = document.createElement('div');
|
||
div.className = 'pending-note queued-message';
|
||
div.dataset.queueId = message.id;
|
||
|
||
const avatar = document.createElement('div');
|
||
avatar.className = 'note-avatar queue-avatar';
|
||
avatar.textContent = 'Q';
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'note-bubble queue-bubble';
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'note-meta queue-meta';
|
||
const waitLabel = isGenerating || currentSessionRunning ? '等待本轮结束' : '即将发送';
|
||
meta.textContent = `队列 · 第 ${index + 1}/${total} 条 · ${waitLabel}`;
|
||
|
||
const text = document.createElement('div');
|
||
text.className = 'note-text';
|
||
text.textContent = message.text;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'note-actions';
|
||
|
||
const upBtn = createNoteActionButton('move-up', '上移', '上移一位');
|
||
upBtn.disabled = index <= 0;
|
||
upBtn.addEventListener('click', () => moveQueuedMessage(message.id, -1));
|
||
|
||
const downBtn = createNoteActionButton('move-down', '下移', '下移一位');
|
||
downBtn.disabled = index >= total - 1;
|
||
downBtn.addEventListener('click', () => moveQueuedMessage(message.id, 1));
|
||
|
||
const editBtn = createNoteActionButton('edit', '修改');
|
||
editBtn.addEventListener('click', () => beginEditQueuedMessage(message.id));
|
||
|
||
const deleteBtn = createNoteActionButton('delete', '删除');
|
||
deleteBtn.addEventListener('click', () => removeQueuedMessage(message.id));
|
||
|
||
actions.append(upBtn, downBtn, editBtn, deleteBtn);
|
||
bubble.append(meta, text, actions);
|
||
div.append(avatar, bubble);
|
||
return div;
|
||
}
|
||
|
||
function renderPendingNotes(options = {}) {
|
||
if (!pendingNotesTray) return;
|
||
pendingNotesTray.innerHTML = '';
|
||
const notes = getCurrentNotes(false);
|
||
const queuedMessages = getCurrentQueue(false);
|
||
renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false });
|
||
if ((!notes || notes.length === 0) && (!queuedMessages || queuedMessages.length === 0)) {
|
||
pendingNotesTray.hidden = true;
|
||
if (options.updateScrollbar !== false) updateScrollbar();
|
||
return;
|
||
}
|
||
|
||
const frag = document.createDocumentFragment();
|
||
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
|
||
queuedMessages.forEach((message, index) => {
|
||
frag.appendChild(createQueuedMessageElement(message, index, queuedMessages.length));
|
||
});
|
||
pendingNotesTray.appendChild(frag);
|
||
pendingNotesTray.hidden = false;
|
||
if (options.scrollIntoView !== false && options.scroll !== false) {
|
||
pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight;
|
||
}
|
||
if (options.updateScrollbar !== false) updateScrollbar();
|
||
}
|
||
|
||
function collectCurrentPendingCcwebPrompts() {
|
||
if (!currentSessionId) return [];
|
||
const prompts = [];
|
||
const seen = new Set();
|
||
const entry = sessionCache.get(currentSessionId);
|
||
const messages = Array.isArray(entry?.snapshot?.messages) ? entry.snapshot.messages : [];
|
||
messages.forEach((message) => {
|
||
const prompt = message?.ccwebPrompt;
|
||
if (!prompt?.id || seen.has(prompt.id) || (prompt.status || 'pending') !== 'pending') return;
|
||
seen.add(prompt.id);
|
||
prompts.push(prompt);
|
||
});
|
||
messagesDiv?.querySelectorAll?.('.ccweb-prompt-card[data-status="pending"]').forEach((card) => {
|
||
const promptId = card.dataset.promptId || '';
|
||
if (!promptId || seen.has(promptId)) return;
|
||
seen.add(promptId);
|
||
prompts.push({
|
||
id: promptId,
|
||
title: card.querySelector('.ccweb-prompt-title')?.textContent || '需要用户确认',
|
||
questions: Array.from(card.querySelectorAll('.ccweb-prompt-question')).map((questionEl) => ({
|
||
id: questionEl.dataset.questionId || '',
|
||
})),
|
||
});
|
||
});
|
||
return prompts;
|
||
}
|
||
|
||
function scrollToCcwebPrompt(promptId) {
|
||
if (!promptId || !messagesDiv) return false;
|
||
const card = messagesDiv.querySelector(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`);
|
||
if (!card) {
|
||
showToast('未找到待提交表单', '可能在未加载的历史消息中');
|
||
return false;
|
||
}
|
||
const target = card.closest('.msg') || card;
|
||
const containerRect = messagesDiv.getBoundingClientRect();
|
||
const targetRect = target.getBoundingClientRect();
|
||
const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 72;
|
||
messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
|
||
card.classList.remove('ccweb-prompt-focus');
|
||
requestAnimationFrame(() => {
|
||
card.classList.add('ccweb-prompt-focus');
|
||
window.setTimeout(() => card.classList.remove('ccweb-prompt-focus'), 1400);
|
||
});
|
||
updateScrollbar();
|
||
return true;
|
||
}
|
||
|
||
function dismissCcwebPrompt(promptId) {
|
||
if (!promptId || !currentSessionId) return;
|
||
send({
|
||
type: 'ccweb_prompt_user_dismiss',
|
||
sessionId: currentSessionId,
|
||
promptId,
|
||
});
|
||
}
|
||
|
||
function createPendingCcwebPromptElement(prompt) {
|
||
const item = document.createElement('div');
|
||
item.className = 'pending-ccweb-prompt';
|
||
item.dataset.promptId = prompt.id || '';
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = 'pending-ccweb-prompt-badge';
|
||
badge.setAttribute('aria-label', '未提交');
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'pending-ccweb-prompt-title';
|
||
const questionCount = Array.isArray(prompt.questions) ? prompt.questions.length : 0;
|
||
title.textContent = `${prompt.title || '需要用户确认'} · ${questionCount || 1} 题`;
|
||
|
||
const action = document.createElement('button');
|
||
action.type = 'button';
|
||
action.className = 'pending-ccweb-prompt-action';
|
||
action.textContent = '定位';
|
||
action.addEventListener('click', () => {
|
||
closeCcwebPromptOutlinePanel();
|
||
scrollToCcwebPrompt(prompt.id);
|
||
});
|
||
|
||
const dismiss = document.createElement('button');
|
||
dismiss.type = 'button';
|
||
dismiss.className = 'pending-ccweb-prompt-dismiss';
|
||
dismiss.textContent = '忽略';
|
||
dismiss.title = '忽略并删除这个未提交表单';
|
||
dismiss.addEventListener('click', () => {
|
||
dismiss.disabled = true;
|
||
dismiss.textContent = '删除中';
|
||
dismissCcwebPrompt(prompt.id);
|
||
});
|
||
|
||
item.append(badge, title, action, dismiss);
|
||
return item;
|
||
}
|
||
|
||
function renderPendingCcwebPrompts(options = {}) {
|
||
if (!ccwebPromptOutlineBtn || !ccwebPromptOutlinePanel) return;
|
||
const prompts = collectCurrentPendingCcwebPrompts();
|
||
const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor');
|
||
if (prompts.length === 0) {
|
||
if (anchor) anchor.hidden = true;
|
||
closeCcwebPromptOutlinePanel();
|
||
delete ccwebPromptOutlineBtn.dataset.count;
|
||
ccwebPromptOutlinePanel.innerHTML = '';
|
||
if (options.updateScrollbar !== false) updateScrollbar();
|
||
return;
|
||
}
|
||
if (anchor) anchor.hidden = false;
|
||
ccwebPromptOutlineBtn.disabled = false;
|
||
ccwebPromptOutlineBtn.dataset.count = String(prompts.length);
|
||
ccwebPromptOutlinePanel.replaceChildren(...prompts.map((prompt) => createPendingCcwebPromptElement(prompt)));
|
||
if (options.updateScrollbar !== false) updateScrollbar();
|
||
}
|
||
|
||
function closeCcwebPromptOutlinePanel() {
|
||
if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return;
|
||
ccwebPromptOutlinePanel.hidden = true;
|
||
ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function toggleCcwebPromptOutlinePanel() {
|
||
if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return;
|
||
if (ccwebPromptOutlinePanel.hidden) {
|
||
renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false });
|
||
const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor');
|
||
if (anchor?.hidden || !ccwebPromptOutlinePanel.children.length) return;
|
||
closeUserOutlinePanel();
|
||
ccwebPromptOutlinePanel.hidden = false;
|
||
ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
closeCcwebPromptOutlinePanel();
|
||
}
|
||
}
|
||
|
||
function findPendingNote(noteId) {
|
||
const key = getCurrentNoteKey();
|
||
const notes = getNotesForKey(key, false);
|
||
const index = notes.findIndex((note) => note.id === noteId);
|
||
if (index === -1) return null;
|
||
return { key, notes, index, note: notes[index] };
|
||
}
|
||
|
||
function dropPendingNote(noteId) {
|
||
const found = findPendingNote(noteId);
|
||
if (!found) return null;
|
||
const [note] = found.notes.splice(found.index, 1);
|
||
cleanupNoteKey(found.key);
|
||
return note;
|
||
}
|
||
|
||
function addPendingNoteFromInput(text) {
|
||
const content = String(text || '').trim();
|
||
if (!content) return false;
|
||
const note = {
|
||
id: `note-${Date.now().toString(36)}-${++noteDraftSeq}`,
|
||
text: content,
|
||
createdAt: Date.now(),
|
||
};
|
||
getCurrentNotes(true).push(note);
|
||
renderPendingNotes();
|
||
return true;
|
||
}
|
||
|
||
function getQueuedMessageValidationError(content) {
|
||
if (!supportsQueuedSend()) return '排队发送仅支持 Codex App。';
|
||
if (!String(content || '').trim()) return '排队内容不能为空。';
|
||
if (String(content || '').trim().startsWith('/')) return '排队发送暂不支持 slash 指令。';
|
||
return '';
|
||
}
|
||
|
||
function addQueuedMessage(text, options = {}) {
|
||
const content = String(text || '').trim();
|
||
const validationError = getQueuedMessageValidationError(content);
|
||
if (validationError) {
|
||
appendError(validationError);
|
||
return false;
|
||
}
|
||
const message = {
|
||
id: `queued-${Date.now().toString(36)}-${++queuedMessageSeq}`,
|
||
text: content,
|
||
createdAt: Date.now(),
|
||
};
|
||
getCurrentQueue(true).push(message);
|
||
if (options.render !== false) renderPendingNotes();
|
||
scheduleQueuedMessageDrain();
|
||
return true;
|
||
}
|
||
|
||
function queueMessageFromInput() {
|
||
const text = msgInput.value.trim();
|
||
if (!text || isBlockingSessionLoad()) return;
|
||
hideCmdMenu();
|
||
hideOptionPicker();
|
||
if (pendingAttachments.length > 0) {
|
||
appendError('排队发送暂不支持图片附件,请先移除图片。');
|
||
return;
|
||
}
|
||
if (addQueuedMessage(text)) {
|
||
msgInput.value = '';
|
||
autoResize();
|
||
}
|
||
}
|
||
|
||
function queuePendingNote(noteId) {
|
||
const found = findPendingNote(noteId);
|
||
if (!found) return;
|
||
const text = String(found.note.text || '').trim();
|
||
const validationError = getQueuedMessageValidationError(text);
|
||
if (validationError) {
|
||
appendError(validationError);
|
||
return;
|
||
}
|
||
dropPendingNote(noteId);
|
||
addQueuedMessage(text, { render: false });
|
||
renderPendingNotes();
|
||
}
|
||
|
||
function findQueuedMessage(queueId) {
|
||
const key = getCurrentQueueKey();
|
||
const queue = getQueueForKey(key, false);
|
||
const index = queue.findIndex((message) => message.id === queueId);
|
||
if (index === -1) return null;
|
||
return { key, queue, index, message: queue[index] };
|
||
}
|
||
|
||
function dropQueuedMessage(queueId) {
|
||
const found = findQueuedMessage(queueId);
|
||
if (!found) return null;
|
||
const [message] = found.queue.splice(found.index, 1);
|
||
cleanupQueueKey(found.key);
|
||
return message;
|
||
}
|
||
|
||
function removeQueuedMessage(queueId) {
|
||
if (!dropQueuedMessage(queueId)) return;
|
||
renderPendingNotes({ scroll: false });
|
||
}
|
||
|
||
function moveQueuedMessage(queueId, delta) {
|
||
const found = findQueuedMessage(queueId);
|
||
if (!found) return;
|
||
const nextIndex = found.index + delta;
|
||
if (nextIndex < 0 || nextIndex >= found.queue.length) return;
|
||
const [message] = found.queue.splice(found.index, 1);
|
||
found.queue.splice(nextIndex, 0, message);
|
||
renderPendingNotes({ scroll: false });
|
||
}
|
||
|
||
function removePendingNote(noteId) {
|
||
if (!dropPendingNote(noteId)) return;
|
||
renderPendingNotes({ scroll: false });
|
||
}
|
||
|
||
function resizeNoteEditor(editor) {
|
||
editor.style.height = 'auto';
|
||
editor.style.height = Math.min(editor.scrollHeight, 180) + 'px';
|
||
}
|
||
|
||
function beginEditPendingNote(noteId) {
|
||
const found = findPendingNote(noteId);
|
||
if (!found) return;
|
||
const noteEl = pendingNotesTray?.querySelector(`.pending-note[data-note-id="${noteId}"]`);
|
||
const bubble = noteEl?.querySelector('.note-bubble');
|
||
if (!bubble) return;
|
||
|
||
bubble.classList.add('editing');
|
||
bubble.innerHTML = '';
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'note-meta';
|
||
meta.textContent = '修改笔记';
|
||
|
||
const editor = document.createElement('textarea');
|
||
editor.className = 'note-edit-input';
|
||
editor.value = found.note.text;
|
||
editor.rows = 3;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'note-actions';
|
||
const saveBtn = createNoteActionButton('save', '保存');
|
||
const cancelBtn = createNoteActionButton('cancel', '取消');
|
||
|
||
const save = () => {
|
||
const next = editor.value.trim();
|
||
if (!next) {
|
||
appendError('笔记内容不能为空。');
|
||
editor.focus();
|
||
return;
|
||
}
|
||
found.note.text = next;
|
||
renderPendingNotes();
|
||
};
|
||
|
||
saveBtn.addEventListener('click', save);
|
||
cancelBtn.addEventListener('click', () => renderPendingNotes({ scroll: false }));
|
||
editor.addEventListener('input', () => resizeNoteEditor(editor));
|
||
editor.addEventListener('keydown', (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
save();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
renderPendingNotes({ scroll: false });
|
||
}
|
||
});
|
||
|
||
actions.append(saveBtn, cancelBtn);
|
||
bubble.append(meta, editor, actions);
|
||
requestAnimationFrame(() => {
|
||
resizeNoteEditor(editor);
|
||
editor.focus();
|
||
editor.select();
|
||
});
|
||
}
|
||
|
||
function beginEditQueuedMessage(queueId) {
|
||
const found = findQueuedMessage(queueId);
|
||
if (!found) return;
|
||
const queueEl = pendingNotesTray?.querySelector(`.queued-message[data-queue-id="${queueId}"]`);
|
||
const bubble = queueEl?.querySelector('.note-bubble');
|
||
if (!bubble) return;
|
||
|
||
bubble.classList.add('editing');
|
||
bubble.innerHTML = '';
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'note-meta queue-meta';
|
||
meta.textContent = '修改排队消息';
|
||
|
||
const editor = document.createElement('textarea');
|
||
editor.className = 'note-edit-input';
|
||
editor.value = found.message.text;
|
||
editor.rows = 3;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'note-actions';
|
||
const saveBtn = createNoteActionButton('save', '保存');
|
||
const cancelBtn = createNoteActionButton('cancel', '取消');
|
||
|
||
const save = () => {
|
||
const next = editor.value.trim();
|
||
const validationError = getQueuedMessageValidationError(next);
|
||
if (validationError) {
|
||
appendError(validationError);
|
||
editor.focus();
|
||
return;
|
||
}
|
||
found.message.text = next;
|
||
renderPendingNotes({ scroll: false });
|
||
};
|
||
|
||
saveBtn.addEventListener('click', save);
|
||
cancelBtn.addEventListener('click', () => renderPendingNotes({ scroll: false }));
|
||
editor.addEventListener('input', () => resizeNoteEditor(editor));
|
||
editor.addEventListener('keydown', (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
save();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
renderPendingNotes({ scroll: false });
|
||
}
|
||
});
|
||
|
||
actions.append(saveBtn, cancelBtn);
|
||
bubble.append(meta, editor, actions);
|
||
requestAnimationFrame(() => {
|
||
resizeNoteEditor(editor);
|
||
editor.focus();
|
||
editor.select();
|
||
});
|
||
}
|
||
|
||
function sendPendingNote(noteId) {
|
||
if (isGenerating || isBlockingSessionLoad()) {
|
||
appendError('当前回复还在生成,稍后再发送笔记。');
|
||
return;
|
||
}
|
||
const note = dropPendingNote(noteId);
|
||
if (!note) return;
|
||
const text = String(note.text || '').trim();
|
||
renderPendingNotes({ scroll: false });
|
||
if (!text) return;
|
||
submitUserMessage(text);
|
||
}
|
||
|
||
function scheduleQueuedMessageDrain() {
|
||
if (queuedMessageDrainTimer) return;
|
||
queuedMessageDrainTimer = setTimeout(() => {
|
||
queuedMessageDrainTimer = null;
|
||
drainQueuedMessages();
|
||
}, 0);
|
||
}
|
||
|
||
function drainQueuedMessages() {
|
||
if (!supportsQueuedSend() || isGenerating || currentSessionRunning || isBlockingSessionLoad()) return;
|
||
const queue = getCurrentQueue(false);
|
||
if (!queue || queue.length === 0) return;
|
||
const [message] = queue.splice(0, 1);
|
||
cleanupQueueKey(getCurrentQueueKey());
|
||
renderPendingNotes({ scroll: false });
|
||
|
||
const text = String(message?.text || '').trim();
|
||
if (!text) {
|
||
scheduleQueuedMessageDrain();
|
||
return;
|
||
}
|
||
submitUserMessage(text);
|
||
}
|
||
|
||
function normalizeTheme(theme) {
|
||
return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi';
|
||
}
|
||
|
||
function getThemeOption(theme) {
|
||
return THEME_OPTIONS.find((item) => item.value === normalizeTheme(theme)) || THEME_OPTIONS[0];
|
||
}
|
||
|
||
function refreshThemeSummaries() {
|
||
const label = getThemeOption(currentTheme).label;
|
||
document.querySelectorAll('[data-theme-summary]').forEach((node) => {
|
||
node.textContent = label;
|
||
});
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
currentTheme = normalizeTheme(theme);
|
||
document.documentElement.dataset.theme = currentTheme;
|
||
localStorage.setItem('cc-web-theme', currentTheme);
|
||
refreshThemeSummaries();
|
||
}
|
||
|
||
function getDividerTimeSummary() {
|
||
return showAgentDividerTime ? '显示时间' : '不显示时间';
|
||
}
|
||
|
||
function refreshDividerTimeControls(root = document) {
|
||
root.querySelectorAll('[data-divider-time-summary]').forEach((node) => {
|
||
node.textContent = getDividerTimeSummary();
|
||
});
|
||
root.querySelectorAll('[data-divider-time-toggle]').forEach((node) => {
|
||
node.checked = showAgentDividerTime;
|
||
});
|
||
}
|
||
|
||
function applyDividerTimePreference(visible) {
|
||
showAgentDividerTime = !!visible;
|
||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||
localStorage.setItem(DIVIDER_TIME_STORAGE_KEY, showAgentDividerTime ? '1' : '0');
|
||
refreshDividerTimeControls();
|
||
}
|
||
|
||
function buildThemePickerHtml(options = {}) {
|
||
const { showSectionTitle = true } = options;
|
||
return `
|
||
${showSectionTitle ? '<div class="settings-section-title">界面主题</div>' : ''}
|
||
<div class="theme-grid">
|
||
${THEME_OPTIONS.map((theme) => `
|
||
<button class="theme-card${theme.value === currentTheme ? ' active' : ''}" type="button" data-theme-value="${theme.value}">
|
||
<div class="theme-card-preview">
|
||
${theme.swatches.map((color) => `<span class="theme-card-swatch" style="background:${color}"></span>`).join('')}
|
||
</div>
|
||
<div class="theme-card-title">${escapeHtml(theme.label)}</div>
|
||
<div class="theme-card-desc">${escapeHtml(theme.desc)}</div>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function mountThemePicker(panel) {
|
||
panel.querySelectorAll('[data-theme-value]').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
applyTheme(button.dataset.themeValue);
|
||
panel.querySelectorAll('[data-theme-value]').forEach((item) => {
|
||
item.classList.toggle('active', item.dataset.themeValue === currentTheme);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function buildAppearanceSettingsHtml() {
|
||
return `
|
||
<div class="settings-section-title">外观</div>
|
||
<button class="settings-nav-card" type="button" data-open-theme-page>
|
||
<span class="settings-nav-card-main">
|
||
<span class="settings-nav-card-title">界面主题</span>
|
||
<span class="settings-nav-card-meta">当前:<span data-theme-summary>${escapeHtml(getThemeOption(currentTheme).label)}</span></span>
|
||
</span>
|
||
<span class="settings-nav-card-arrow" aria-hidden="true">›</span>
|
||
</button>
|
||
<label class="settings-toggle-row">
|
||
<span class="settings-toggle-copy">
|
||
<span class="settings-toggle-title">分隔线时间</span>
|
||
<span class="settings-toggle-meta">当前:<span data-divider-time-summary>${escapeHtml(getDividerTimeSummary())}</span></span>
|
||
</span>
|
||
<span class="settings-switch">
|
||
<input type="checkbox" data-divider-time-toggle ${showAgentDividerTime ? 'checked' : ''}>
|
||
<span class="settings-switch-track" aria-hidden="true">
|
||
<span class="settings-switch-thumb"></span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
`;
|
||
}
|
||
|
||
function mountAppearanceSettings(panel) {
|
||
const themePageBtn = panel.querySelector('[data-open-theme-page]');
|
||
if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage);
|
||
const dividerTimeToggle = panel.querySelector('[data-divider-time-toggle]');
|
||
if (dividerTimeToggle) {
|
||
dividerTimeToggle.checked = showAgentDividerTime;
|
||
dividerTimeToggle.addEventListener('change', () => {
|
||
applyDividerTimePreference(dividerTimeToggle.checked);
|
||
});
|
||
}
|
||
refreshDividerTimeControls(panel);
|
||
}
|
||
|
||
function buildNotifyEntryHtml(config) {
|
||
const provider = config?.provider || 'off';
|
||
const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
|
||
const summaryOn = config?.summary?.enabled ? '摘要已启用' : '摘要关闭';
|
||
const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`;
|
||
return `
|
||
<div class="settings-section-title">通知</div>
|
||
<button class="settings-nav-card" type="button" data-open-notify-page>
|
||
<span class="settings-nav-card-main">
|
||
<span class="settings-nav-card-title">通知设置</span>
|
||
<span class="settings-nav-card-meta" data-notify-summary>${escapeHtml(meta)}</span>
|
||
</span>
|
||
<span class="settings-nav-card-arrow" aria-hidden="true">›</span>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
function openNotifySubpage() {
|
||
send({ type: 'get_notify_config' });
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay settings-subpage-overlay';
|
||
overlay.style.zIndex = '10001';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'settings-panel settings-subpage-panel';
|
||
panel.innerHTML = `
|
||
<div class="settings-header settings-subpage-header">
|
||
<button class="settings-back" type="button" aria-label="返回">‹</button>
|
||
<div class="settings-subpage-copy">
|
||
<div class="settings-subpage-kicker">Notification</div>
|
||
<h3>通知设置</h3>
|
||
</div>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>通知方式</label>
|
||
<select class="settings-select" id="notify-provider">
|
||
${PROVIDER_OPTIONS.map(o => `<option value="${o.value}">${escapeHtml(o.label)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div id="notify-fields"></div>
|
||
<div id="notify-summary-area"></div>
|
||
<div class="settings-actions">
|
||
<button class="btn-test" id="notify-test-btn">测试</button>
|
||
<button class="btn-save" id="notify-save-btn">保存</button>
|
||
</div>
|
||
<div class="settings-status" id="notify-status"></div>
|
||
`;
|
||
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
|
||
const providerSelect = panel.querySelector('#notify-provider');
|
||
const fieldsDiv = panel.querySelector('#notify-fields');
|
||
const summaryArea = panel.querySelector('#notify-summary-area');
|
||
const statusDiv = panel.querySelector('#notify-status');
|
||
const testBtn = panel.querySelector('#notify-test-btn');
|
||
const saveBtn = panel.querySelector('#notify-save-btn');
|
||
|
||
let currentNotifyConfig = null;
|
||
|
||
function renderFields(provider) {
|
||
renderNotifyFields(fieldsDiv, currentNotifyConfig, provider);
|
||
if (summaryArea) {
|
||
summaryArea.innerHTML = buildSummarySettingsHtml(currentNotifyConfig);
|
||
bindSummarySettingsEvents(panel);
|
||
}
|
||
}
|
||
|
||
function collectConfig() {
|
||
return collectNotifyConfigFromPanel(panel, currentNotifyConfig, providerSelect.value);
|
||
}
|
||
|
||
function showStatus(msg, type) {
|
||
statusDiv.textContent = msg;
|
||
statusDiv.className = 'settings-status ' + (type || '');
|
||
}
|
||
|
||
function refreshParentSummary(config) {
|
||
const provider = config?.provider || 'off';
|
||
const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
|
||
const summaryOn = config?.summary?.enabled ? '摘要已启用' : '摘要关闭';
|
||
const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`;
|
||
document.querySelectorAll('[data-notify-summary]').forEach(el => { el.textContent = meta; });
|
||
}
|
||
|
||
const savedOnNotifyConfig = _onNotifyConfig;
|
||
_onNotifyConfig = (config) => {
|
||
currentNotifyConfig = config;
|
||
providerSelect.value = config.provider || 'off';
|
||
renderFields(config.provider || 'off');
|
||
if (savedOnNotifyConfig) savedOnNotifyConfig(config);
|
||
};
|
||
|
||
const savedOnNotifyTestResult = _onNotifyTestResult;
|
||
_onNotifyTestResult = (msg) => {
|
||
showStatus(msg.message, msg.success ? 'success' : 'error');
|
||
if (savedOnNotifyTestResult) savedOnNotifyTestResult(msg);
|
||
};
|
||
|
||
providerSelect.addEventListener('change', () => renderFields(providerSelect.value));
|
||
|
||
testBtn.addEventListener('click', () => {
|
||
const config = collectConfig();
|
||
send({ type: 'save_notify_config', config });
|
||
showStatus('正在发送测试消息...', '');
|
||
send({ type: 'test_notify' });
|
||
});
|
||
|
||
saveBtn.addEventListener('click', () => {
|
||
const config = collectConfig();
|
||
send({ type: 'save_notify_config', config });
|
||
refreshParentSummary(config);
|
||
showStatus('已保存', 'success');
|
||
});
|
||
|
||
const closeSubpage = () => {
|
||
_onNotifyConfig = savedOnNotifyConfig;
|
||
_onNotifyTestResult = savedOnNotifyTestResult;
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
};
|
||
|
||
panel.querySelector('.settings-back').addEventListener('click', closeSubpage);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSubpage(); });
|
||
}
|
||
|
||
function openThemeSubpage() {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay settings-subpage-overlay';
|
||
overlay.style.zIndex = '10001';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'settings-panel settings-subpage-panel';
|
||
panel.innerHTML = `
|
||
<div class="settings-header settings-subpage-header">
|
||
<button class="settings-back" type="button" aria-label="返回">‹</button>
|
||
<div class="settings-subpage-copy">
|
||
<div class="settings-subpage-kicker">Appearance</div>
|
||
<h3>界面主题</h3>
|
||
</div>
|
||
<button class="settings-close" type="button" title="关闭">×</button>
|
||
</div>
|
||
${buildThemePickerHtml({ showSectionTitle: false })}
|
||
`;
|
||
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
mountThemePicker(panel);
|
||
refreshThemeSummaries();
|
||
|
||
const closeSubpage = () => {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
};
|
||
|
||
panel.querySelector('.settings-back').addEventListener('click', closeSubpage);
|
||
panel.querySelector('.settings-close').addEventListener('click', closeSubpage);
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) closeSubpage();
|
||
});
|
||
}
|
||
|
||
function getAgentSessionStorageKey(agent) {
|
||
return `cc-web-session-${normalizeAgent(agent)}`;
|
||
}
|
||
|
||
function getAgentModeStorageKey(agent) {
|
||
return `cc-web-mode-${normalizeAgent(agent)}`;
|
||
}
|
||
|
||
function getLastSessionForAgent(agent) {
|
||
return localStorage.getItem(getAgentSessionStorageKey(agent));
|
||
}
|
||
|
||
function setLastSessionForAgent(agent, sessionId) {
|
||
localStorage.setItem(getAgentSessionStorageKey(agent), sessionId);
|
||
localStorage.setItem('cc-web-session', sessionId);
|
||
}
|
||
|
||
function getSessionMeta(sessionId) {
|
||
return sessions.find((s) => s.id === sessionId) || null;
|
||
}
|
||
|
||
function compareSessionUpdatedDesc(a, b) {
|
||
return new Date(b?.updated || 0) - new Date(a?.updated || 0);
|
||
}
|
||
|
||
function compareSessionPinnedDesc(a, b) {
|
||
const pinnedDiff = new Date(b?.pinnedAt || 0) - new Date(a?.pinnedAt || 0);
|
||
return pinnedDiff || compareSessionUpdatedDesc(a, b);
|
||
}
|
||
|
||
function shortSessionId(sessionId) {
|
||
const value = String(sessionId || '');
|
||
return value ? value.slice(0, 8) : '';
|
||
}
|
||
|
||
function shortChildAgentId(threadId) {
|
||
const value = String(threadId || '');
|
||
if (!value) return '';
|
||
if (value.length <= 13) return value;
|
||
return `${value.slice(0, 8)}…${value.slice(-4)}`;
|
||
}
|
||
|
||
function shortMessagePreview(text, maxLength = 60) {
|
||
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
||
if (!value) return '空消息';
|
||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||
}
|
||
|
||
function getCrossConversationReplyCollapseKey(meta = {}) {
|
||
const source = meta?.crossConversation || {};
|
||
const messageId = source.replyToRequestId
|
||
|| source.messageId
|
||
|| meta.messageId
|
||
|| meta.id
|
||
|| [source.sourceSessionId, meta.timestamp || source.processedAt || source.sentAt].filter(Boolean).join(':');
|
||
if (!messageId) return '';
|
||
return `${currentSessionId || 'unknown'}:${messageId}`;
|
||
}
|
||
|
||
function persistCrossConversationReplyCollapseState() {
|
||
try {
|
||
const keys = Array.from(collapsedCrossConversationReplyKeys).slice(-CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT);
|
||
localStorage.setItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY, JSON.stringify(keys));
|
||
} catch {}
|
||
}
|
||
|
||
function setCrossConversationReplyCollapsed(key, collapsed) {
|
||
if (!key) return;
|
||
if (collapsed) {
|
||
collapsedCrossConversationReplyKeys.delete(key);
|
||
collapsedCrossConversationReplyKeys.add(key);
|
||
} else {
|
||
collapsedCrossConversationReplyKeys.delete(key);
|
||
}
|
||
persistCrossConversationReplyCollapseState();
|
||
}
|
||
|
||
function isCrossConversationReplyCollapsed(key) {
|
||
return !!key && collapsedCrossConversationReplyKeys.has(key);
|
||
}
|
||
|
||
function formatCrossConversationReplyTime(value) {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function createLocalId(prefix = 'local') {
|
||
if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`;
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
function clearUserMessageIndex() {
|
||
userMessageIndex.clear();
|
||
}
|
||
|
||
function registerUserMessage(messageId, element, content) {
|
||
if (!messageId || !element) return;
|
||
userMessageIndex.set(messageId, {
|
||
id: messageId,
|
||
element,
|
||
content: String(content || ''),
|
||
});
|
||
}
|
||
|
||
function buildUserOutlineItems() {
|
||
const seen = new Set();
|
||
return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => {
|
||
const id = String(element.dataset.messageId || '').trim();
|
||
if (!id || seen.has(id)) return null;
|
||
seen.add(id);
|
||
const indexed = userMessageIndex.get(id);
|
||
const content = indexed?.content || element.querySelector('.msg-text')?.textContent || '';
|
||
return {
|
||
id,
|
||
targetMessageId: element.id || '',
|
||
label: shortMessagePreview(content, 64),
|
||
};
|
||
}).filter((entry) => entry && entry.targetMessageId);
|
||
}
|
||
|
||
function updateUserOutlinePanel() {
|
||
if (!userOutlinePanel || !userOutlineBtn) return;
|
||
const items = buildUserOutlineItems();
|
||
if (items.length === 0) {
|
||
userOutlinePanel.innerHTML = '<div class="user-outline-empty">暂无用户消息</div>';
|
||
userOutlineBtn.disabled = true;
|
||
} else {
|
||
userOutlinePanel.innerHTML = items.map((item, index) => `
|
||
<button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId)}">
|
||
<span class="user-outline-index">${index + 1}</span>
|
||
<span class="user-outline-text">${escapeHtml(item.label)}</span>
|
||
</button>
|
||
`).join('');
|
||
userOutlineBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function closeUserOutlinePanel() {
|
||
if (!userOutlinePanel || !userOutlineBtn) return;
|
||
userOutlinePanel.hidden = true;
|
||
userOutlineBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function toggleUserOutlinePanel() {
|
||
if (!userOutlinePanel || !userOutlineBtn) return;
|
||
if (userOutlinePanel.hidden) {
|
||
updateUserOutlinePanel();
|
||
closeCcwebPromptOutlinePanel();
|
||
userOutlinePanel.hidden = false;
|
||
userOutlineBtn.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
closeUserOutlinePanel();
|
||
}
|
||
}
|
||
|
||
function scrollToMessage(anchorId) {
|
||
if (!anchorId) return;
|
||
const target = document.getElementById(anchorId);
|
||
if (!target || !messagesDiv.contains(target)) return;
|
||
const containerRect = messagesDiv.getBoundingClientRect();
|
||
const targetRect = target.getBoundingClientRect();
|
||
const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 12;
|
||
messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
|
||
}
|
||
|
||
function isAssistantLastSectionTextNode(node, root) {
|
||
if (!node || node.nodeType !== Node.TEXT_NODE || !node.nodeValue?.trim()) return false;
|
||
const parent = node.parentElement;
|
||
if (!parent || !root.contains(parent)) return false;
|
||
if (parent.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false;
|
||
const tag = parent.tagName?.toLowerCase();
|
||
return !['button', 'script', 'style', 'textarea', 'input', 'select', 'option'].includes(tag);
|
||
}
|
||
|
||
function collectAssistantTextNodes(root) {
|
||
const nodes = [];
|
||
if (!root) return nodes;
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||
acceptNode(node) {
|
||
return isAssistantLastSectionTextNode(node, root)
|
||
? NodeFilter.FILTER_ACCEPT
|
||
: NodeFilter.FILTER_REJECT;
|
||
},
|
||
});
|
||
let node = walker.nextNode();
|
||
while (node) {
|
||
nodes.push(node);
|
||
node = walker.nextNode();
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
function findLastAssistantTextScope(bubble) {
|
||
const nodes = collectAssistantTextNodes(bubble);
|
||
const lastNode = nodes[nodes.length - 1];
|
||
if (!lastNode) return null;
|
||
return lastNode.parentElement?.closest(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR) || bubble;
|
||
}
|
||
|
||
function collectAssistantTextScopes(root) {
|
||
if (!root) return [];
|
||
const seen = new Set();
|
||
return Array.from(root.querySelectorAll(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR)).filter((scope) => {
|
||
if (!scope || seen.has(scope)) return false;
|
||
seen.add(scope);
|
||
if (scope.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false;
|
||
return collectAssistantTextNodes(scope).length > 0;
|
||
});
|
||
}
|
||
|
||
function findFirstAssistantTextScopeAfterDivider(bubble) {
|
||
const dividers = Array.from(bubble?.querySelectorAll?.('.agent-message-divider') || []);
|
||
const lastDivider = dividers[dividers.length - 1];
|
||
if (!lastDivider) return null;
|
||
return collectAssistantTextScopes(bubble).find((scope) => (
|
||
lastDivider.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING
|
||
)) || null;
|
||
}
|
||
|
||
function findFirstNonWhitespaceIndex(text, start = 0) {
|
||
for (let i = Math.max(0, start); i < text.length; i += 1) {
|
||
if (!/\s/.test(text[i])) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function mapTextIndexToNode(entries, index) {
|
||
for (const entry of entries) {
|
||
if (index >= entry.start && index < entry.end) {
|
||
return { node: entry.node, offset: index - entry.start };
|
||
}
|
||
}
|
||
const last = entries[entries.length - 1];
|
||
return last ? { node: last.node, offset: last.node.nodeValue.length } : null;
|
||
}
|
||
|
||
function getAssistantTextScopeStartTarget(scope) {
|
||
if (!scope) return null;
|
||
const nodes = collectAssistantTextNodes(scope);
|
||
if (nodes.length === 0) return null;
|
||
const entries = [];
|
||
let text = '';
|
||
nodes.forEach((node) => {
|
||
const value = node.nodeValue || '';
|
||
const start = text.length;
|
||
text += value;
|
||
entries.push({ node, start, end: text.length });
|
||
});
|
||
const startIndex = findFirstNonWhitespaceIndex(text, 0);
|
||
if (startIndex < 0) return null;
|
||
const mapped = mapTextIndexToNode(entries, startIndex);
|
||
return mapped ? { ...mapped, scope } : null;
|
||
}
|
||
|
||
function getAssistantLastSectionTarget(bubble) {
|
||
const scope = findFirstAssistantTextScopeAfterDivider(bubble) || findLastAssistantTextScope(bubble);
|
||
return getAssistantTextScopeStartTarget(scope);
|
||
}
|
||
|
||
function getRangeRectFromTextPosition(node, offset) {
|
||
if (!node) return null;
|
||
const range = document.createRange();
|
||
const safeOffset = Math.min(Math.max(0, offset), node.nodeValue.length);
|
||
range.setStart(node, safeOffset);
|
||
range.setEnd(node, Math.min(node.nodeValue.length, safeOffset + 1));
|
||
const rect = Array.from(range.getClientRects()).find(item => item.width || item.height) || null;
|
||
range.detach?.();
|
||
return rect;
|
||
}
|
||
|
||
function scrollAssistantBubbleToLastSection(bubble) {
|
||
const target = getAssistantLastSectionTarget(bubble);
|
||
if (!target) return false;
|
||
const rect = getRangeRectFromTextPosition(target.node, target.offset) || target.scope.getBoundingClientRect();
|
||
if (!rect) return false;
|
||
const containerRect = messagesDiv.getBoundingClientRect();
|
||
const targetTop = messagesDiv.scrollTop + rect.top - containerRect.top - ASSISTANT_LAST_SECTION_SCROLL_OFFSET;
|
||
messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
|
||
target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS);
|
||
requestAnimationFrame(() => {
|
||
target.scope.classList.add(ASSISTANT_LAST_SECTION_FOCUS_CLASS);
|
||
window.setTimeout(() => target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS), 1100);
|
||
});
|
||
updateScrollbar();
|
||
return true;
|
||
}
|
||
|
||
function createAssistantLastSectionButton() {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = ASSISTANT_LAST_SECTION_BUTTON_CLASS;
|
||
button.title = '定位到本条回复最后一段';
|
||
button.setAttribute('aria-label', '定位到本条回复最后一段');
|
||
button.innerHTML = `
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="7"></circle>
|
||
<circle cx="12" cy="12" r="2"></circle>
|
||
<path d="M12 2v3"></path>
|
||
<path d="M12 19v3"></path>
|
||
<path d="M2 12h3"></path>
|
||
<path d="M19 12h3"></path>
|
||
</svg>
|
||
`;
|
||
button.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const bubble = button.closest('.msg-bubble');
|
||
scrollAssistantBubbleToLastSection(bubble);
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function getMessageActionRow(bubble) {
|
||
if (!bubble) return null;
|
||
let row = bubble.querySelector(':scope > .msg-action-row');
|
||
if (!row) {
|
||
row = document.createElement('div');
|
||
row.className = 'msg-action-row';
|
||
bubble.appendChild(row);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function syncAssistantLastSectionButton(messageEl) {
|
||
if (!messageEl?.classList?.contains('assistant')) return;
|
||
const bubble = messageEl.querySelector(':scope > .msg-bubble');
|
||
if (!bubble) return;
|
||
let button = bubble.querySelector(`:scope .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`);
|
||
const hasTarget = !!getAssistantLastSectionTarget(bubble);
|
||
if (!button && !hasTarget) return;
|
||
if (!button) button = createAssistantLastSectionButton();
|
||
button.hidden = !hasTarget;
|
||
button.disabled = !hasTarget;
|
||
getMessageActionRow(bubble)?.appendChild(button);
|
||
}
|
||
|
||
function createAssistantBranchButton(messageIndex) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = ASSISTANT_BRANCH_BUTTON_CLASS;
|
||
button.title = '从这里分支新会话';
|
||
button.setAttribute('aria-label', '从这里分支新会话');
|
||
button.dataset.messageIndex = String(messageIndex);
|
||
button.innerHTML = `
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="M6 3v12"></path>
|
||
<circle cx="6" cy="18" r="3"></circle>
|
||
<circle cx="18" cy="6" r="3"></circle>
|
||
<path d="M6 9h6a6 6 0 0 0 6-6"></path>
|
||
<path d="M15 18h6"></path>
|
||
<path d="M18 15v6"></path>
|
||
</svg>
|
||
`;
|
||
button.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const index = Number.parseInt(button.dataset.messageIndex || '', 10);
|
||
branchFromAssistantMessage(index);
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function syncAssistantBranchButton(messageEl, messageIndex) {
|
||
if (!messageEl?.classList?.contains('assistant')) return;
|
||
if (!currentSessionId || !Number.isFinite(messageIndex) || messageIndex < 0) return;
|
||
const bubble = messageEl.querySelector(':scope > .msg-bubble');
|
||
if (!bubble) return;
|
||
let button = bubble.querySelector(`:scope .${ASSISTANT_BRANCH_BUTTON_CLASS}`);
|
||
if (!button) button = createAssistantBranchButton(messageIndex);
|
||
button.dataset.messageIndex = String(messageIndex);
|
||
getMessageActionRow(bubble)?.appendChild(button);
|
||
}
|
||
|
||
function markSessionMessageElement(messageEl, messageIndex) {
|
||
if (!messageEl || !Number.isFinite(messageIndex) || messageIndex < 0) return;
|
||
messageEl.dataset.sessionMessage = 'true';
|
||
messageEl.dataset.messageIndex = String(messageIndex);
|
||
if (messageEl.classList.contains('assistant')) {
|
||
syncAssistantBranchButton(messageEl, messageIndex);
|
||
}
|
||
}
|
||
|
||
function updateSessionIdBadge() {
|
||
if (!chatSessionIdBtn) return;
|
||
if (!currentSessionId) {
|
||
chatSessionIdBtn.hidden = true;
|
||
chatSessionIdBtn.textContent = 'ID';
|
||
chatSessionIdBtn.title = '复制当前会话 ID';
|
||
return;
|
||
}
|
||
chatSessionIdBtn.hidden = false;
|
||
chatSessionIdBtn.textContent = `ID ${shortSessionId(currentSessionId)}`;
|
||
chatSessionIdBtn.title = `复制当前会话 ID\n${currentSessionId}`;
|
||
chatSessionIdBtn.setAttribute('aria-label', `复制当前会话 ID ${currentSessionId}`);
|
||
}
|
||
|
||
function shouldPreferTextareaCopy() {
|
||
const ua = navigator.userAgent || '';
|
||
return /iPad|iPhone|iPod/.test(ua)
|
||
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||
}
|
||
|
||
function copyTextWithTextarea(value) {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = value;
|
||
textarea.setAttribute('readonly', '');
|
||
textarea.setAttribute('aria-hidden', 'true');
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.top = '0';
|
||
textarea.style.left = '0';
|
||
textarea.style.width = '1px';
|
||
textarea.style.height = '1px';
|
||
textarea.style.padding = '0';
|
||
textarea.style.border = '0';
|
||
textarea.style.opacity = '0';
|
||
textarea.style.pointerEvents = 'none';
|
||
textarea.style.fontSize = '16px';
|
||
document.body.appendChild(textarea);
|
||
|
||
const activeElement = document.activeElement;
|
||
try {
|
||
try {
|
||
textarea.focus({ preventScroll: true });
|
||
} catch {
|
||
textarea.focus();
|
||
}
|
||
textarea.select();
|
||
try {
|
||
textarea.setSelectionRange(0, value.length);
|
||
} catch {}
|
||
if (!document.execCommand('copy')) throw new Error('copy_failed');
|
||
} finally {
|
||
textarea.remove();
|
||
if (activeElement && typeof activeElement.focus === 'function') {
|
||
try {
|
||
activeElement.focus({ preventScroll: true });
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function copyTextToClipboard(text, successText = '已复制') {
|
||
const value = String(text || '');
|
||
if (!value) return false;
|
||
try {
|
||
let copied = false;
|
||
const preferTextarea = shouldPreferTextareaCopy();
|
||
if (preferTextarea) {
|
||
try {
|
||
copyTextWithTextarea(value);
|
||
copied = true;
|
||
} catch {}
|
||
}
|
||
if (!copied && navigator.clipboard?.writeText && window.isSecureContext) {
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
copied = true;
|
||
} catch {}
|
||
}
|
||
if (!copied) copyTextWithTextarea(value);
|
||
showToast(successText);
|
||
return true;
|
||
} catch {
|
||
showToast('复制失败');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function deepClone(value) {
|
||
if (value === null || value === undefined) return value;
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function cloneMessages(messages) {
|
||
return Array.isArray(messages) ? deepClone(messages) : [];
|
||
}
|
||
|
||
function estimateSessionMessageWeight(message) {
|
||
const content = typeof message?.content === 'string' ? message.content.length : JSON.stringify(message?.content || '').length;
|
||
const toolCalls = Array.isArray(message?.toolCalls) ? JSON.stringify(message.toolCalls).length : 0;
|
||
return content + toolCalls + 64;
|
||
}
|
||
|
||
function estimateSessionSnapshotWeight(snapshot) {
|
||
const base = JSON.stringify({
|
||
title: snapshot.title || '',
|
||
mode: snapshot.mode || '',
|
||
model: snapshot.model || '',
|
||
agent: snapshot.agent || '',
|
||
cwd: snapshot.cwd || '',
|
||
updated: snapshot.updated || '',
|
||
}).length;
|
||
return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0);
|
||
}
|
||
|
||
function normalizeSessionSnapshot(payload, options = {}) {
|
||
const sessionId = payload.sessionId || payload.id || '';
|
||
const messages = cloneMessages(payload.messages || []);
|
||
const historyTotal = Number.isFinite(Number(payload.historyTotal))
|
||
? Math.max(0, Number(payload.historyTotal))
|
||
: messages.length;
|
||
const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex))
|
||
? Math.max(0, Number(payload.historyBaseIndex))
|
||
: Math.max(0, historyTotal - messages.length);
|
||
return {
|
||
sessionId,
|
||
id: sessionId,
|
||
messages,
|
||
title: payload.title || '新会话',
|
||
mode: payload.mode || 'yolo',
|
||
model: payload.model || '',
|
||
agent: normalizeAgent(payload.agent),
|
||
pinnedAt: payload.pinnedAt || null,
|
||
hasUnread: !!payload.hasUnread,
|
||
cwd: payload.cwd || null,
|
||
projectName: payload.projectName || '',
|
||
oversized: !!payload.oversized,
|
||
fileBytes: Number(payload.fileBytes || 0),
|
||
totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
|
||
totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null,
|
||
updated: payload.updated || null,
|
||
isRunning: !!payload.isRunning,
|
||
waitingOnChildren: !!payload.waitingOnChildren,
|
||
pendingReplyCount: Number(payload.pendingReplyCount || 0),
|
||
readyReplyCount: Number(payload.readyReplyCount || 0),
|
||
waitingReplyCount: Number(payload.waitingReplyCount || 0),
|
||
failedReplyCount: Number(payload.failedReplyCount || 0),
|
||
pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [],
|
||
historyTotal,
|
||
historyBaseIndex,
|
||
historyPending: !!payload.historyPending,
|
||
complete: options.complete !== undefined ? !!options.complete : !payload.historyPending,
|
||
};
|
||
}
|
||
|
||
function touchSessionCache(sessionId) {
|
||
const entry = sessionCache.get(sessionId);
|
||
if (entry) entry.lastUsed = Date.now();
|
||
}
|
||
|
||
function invalidateSessionCache(sessionId) {
|
||
if (!sessionId) return;
|
||
sessionCache.delete(sessionId);
|
||
}
|
||
|
||
function pruneSessionCache() {
|
||
let totalWeight = 0;
|
||
for (const entry of sessionCache.values()) totalWeight += entry.weight || 0;
|
||
while (sessionCache.size > SESSION_CACHE_LIMIT || totalWeight > SESSION_CACHE_MAX_WEIGHT) {
|
||
let oldestId = null;
|
||
let oldestTs = Infinity;
|
||
for (const [sessionId, entry] of sessionCache) {
|
||
if ((entry.lastUsed || 0) < oldestTs) {
|
||
oldestTs = entry.lastUsed || 0;
|
||
oldestId = sessionId;
|
||
}
|
||
}
|
||
if (!oldestId) break;
|
||
totalWeight -= sessionCache.get(oldestId)?.weight || 0;
|
||
sessionCache.delete(oldestId);
|
||
}
|
||
}
|
||
|
||
function cacheSessionSnapshot(snapshot) {
|
||
if (!snapshot?.sessionId || !snapshot.complete) return;
|
||
const cachedSnapshot = deepClone(snapshot);
|
||
const weight = estimateSessionSnapshotWeight(cachedSnapshot);
|
||
if (weight > SESSION_CACHE_MAX_WEIGHT) {
|
||
invalidateSessionCache(cachedSnapshot.sessionId);
|
||
return;
|
||
}
|
||
const meta = getSessionMeta(cachedSnapshot.sessionId);
|
||
sessionCache.set(cachedSnapshot.sessionId, {
|
||
snapshot: cachedSnapshot,
|
||
version: cachedSnapshot.updated || null,
|
||
meta: meta ? deepClone(meta) : null,
|
||
weight,
|
||
lastUsed: Date.now(),
|
||
});
|
||
pruneSessionCache();
|
||
}
|
||
|
||
function updateCachedSession(sessionId, updater) {
|
||
const entry = sessionCache.get(sessionId);
|
||
if (!entry) return;
|
||
const nextSnapshot = deepClone(entry.snapshot);
|
||
updater(nextSnapshot);
|
||
entry.snapshot = nextSnapshot;
|
||
entry.weight = estimateSessionSnapshotWeight(nextSnapshot);
|
||
entry.lastUsed = Date.now();
|
||
if (nextSnapshot.updated) entry.version = nextSnapshot.updated;
|
||
pruneSessionCache();
|
||
}
|
||
|
||
function reconcileSessionCacheWithSessions() {
|
||
const knownIds = new Set(sessions.map((session) => session.id));
|
||
for (const [sessionId, entry] of sessionCache) {
|
||
if (!knownIds.has(sessionId)) {
|
||
sessionCache.delete(sessionId);
|
||
continue;
|
||
}
|
||
const meta = getSessionMeta(sessionId);
|
||
entry.meta = meta ? deepClone(meta) : null;
|
||
}
|
||
}
|
||
|
||
function mergeSessionListSnapshot(snapshot) {
|
||
if (!snapshot?.sessionId) return;
|
||
const nextMeta = {
|
||
id: snapshot.sessionId,
|
||
sessionId: snapshot.sessionId,
|
||
cwd: snapshot.cwd || '',
|
||
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : snapshot.projectName || '',
|
||
title: snapshot.title || '新会话',
|
||
agent: normalizeAgent(snapshot.agent),
|
||
updated: snapshot.updated || new Date().toISOString(),
|
||
pinnedAt: snapshot.pinnedAt || null,
|
||
hasUnread: !!snapshot.hasUnread,
|
||
isRunning: !!snapshot.isRunning,
|
||
waitingOnChildren: !!snapshot.waitingOnChildren,
|
||
pendingReplyCount: Number(snapshot.pendingReplyCount || 0),
|
||
readyReplyCount: Number(snapshot.readyReplyCount || 0),
|
||
waitingReplyCount: Number(snapshot.waitingReplyCount || 0),
|
||
failedReplyCount: Number(snapshot.failedReplyCount || 0),
|
||
oversized: !!snapshot.oversized,
|
||
fileBytes: Number(snapshot.fileBytes || 0),
|
||
};
|
||
let found = false;
|
||
sessions = sessions.map((session) => {
|
||
if (session.id !== snapshot.sessionId) return session;
|
||
found = true;
|
||
return {
|
||
...session,
|
||
...nextMeta,
|
||
cwd: nextMeta.cwd || session.cwd || '',
|
||
projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '',
|
||
title: nextMeta.title || session.title,
|
||
};
|
||
});
|
||
if (!found) {
|
||
sessions = [nextMeta, ...sessions].sort(compareSessionUpdatedDesc);
|
||
}
|
||
}
|
||
|
||
function getSessionCacheDisposition(sessionId) {
|
||
const entry = sessionCache.get(sessionId);
|
||
const meta = getSessionMeta(sessionId);
|
||
if (!entry?.snapshot?.complete || !meta) return 'miss';
|
||
if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) {
|
||
return 'strong';
|
||
}
|
||
return 'weak';
|
||
}
|
||
|
||
function buildCachedSessionSnapshot(sessionId) {
|
||
const entry = sessionCache.get(sessionId);
|
||
if (!entry?.snapshot) return null;
|
||
const snapshot = deepClone(entry.snapshot);
|
||
const meta = getSessionMeta(sessionId) || entry.meta;
|
||
if (meta) {
|
||
snapshot.title = meta.title || snapshot.title;
|
||
snapshot.agent = normalizeAgent(meta.agent || snapshot.agent);
|
||
snapshot.hasUnread = !!meta.hasUnread;
|
||
snapshot.updated = meta.updated || snapshot.updated;
|
||
snapshot.pinnedAt = meta.pinnedAt || null;
|
||
snapshot.isRunning = !!meta.isRunning;
|
||
snapshot.waitingOnChildren = !!meta.waitingOnChildren;
|
||
snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0);
|
||
snapshot.readyReplyCount = Number(meta.readyReplyCount || 0);
|
||
snapshot.waitingReplyCount = Number(meta.waitingReplyCount || 0);
|
||
snapshot.failedReplyCount = Number(meta.failedReplyCount || 0);
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
const size = Number(bytes) || 0;
|
||
if (size < 1024) return `${size}B`;
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
|
||
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
|
||
}
|
||
|
||
function normalizeBrowserPath(input) {
|
||
return String(input || '')
|
||
.replace(/\\/g, '/')
|
||
.split('/')
|
||
.filter((part) => part && part !== '.')
|
||
.join('/');
|
||
}
|
||
|
||
function getPathLeaf(input) {
|
||
const normalized = String(input || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||
if (!normalized) return '';
|
||
const parts = normalized.split('/');
|
||
return parts[parts.length - 1] || normalized;
|
||
}
|
||
|
||
function getBrowserParentPath(currentPath) {
|
||
const normalized = normalizeBrowserPath(currentPath);
|
||
if (!normalized) return '';
|
||
const parts = normalized.split('/');
|
||
parts.pop();
|
||
return parts.join('/');
|
||
}
|
||
|
||
function getBrowserDisplayPath(rootPath, currentPath) {
|
||
const root = String(rootPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||
const current = normalizeBrowserPath(currentPath);
|
||
return current ? `${root}/${current}` : root;
|
||
}
|
||
|
||
function normalizeAbsoluteDisplayPath(input) {
|
||
return String(input || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
||
}
|
||
|
||
function isAbsoluteDisplayPath(input) {
|
||
const value = normalizeAbsoluteDisplayPath(input);
|
||
return value.startsWith('/') || /^[A-Za-z]:\//.test(value);
|
||
}
|
||
|
||
function getWorkspaceRelativePath(input) {
|
||
const filePath = normalizeAbsoluteDisplayPath(input);
|
||
if (!filePath) return '';
|
||
if (!isAbsoluteDisplayPath(filePath)) return normalizeBrowserPath(filePath);
|
||
|
||
const rootPath = normalizeAbsoluteDisplayPath(currentCwd);
|
||
if (!rootPath) return '';
|
||
if (filePath === rootPath) return '';
|
||
if (!filePath.startsWith(`${rootPath}/`)) return '';
|
||
return normalizeBrowserPath(filePath.slice(rootPath.length + 1));
|
||
}
|
||
|
||
function parseLocalFileLinkHref(rawHref) {
|
||
const raw = String(rawHref || '').trim();
|
||
if (!raw || raw.startsWith('#')) return null;
|
||
|
||
let decoded = raw;
|
||
try { decoded = decodeURI(raw); } catch {}
|
||
|
||
if (/^file:\/\//i.test(decoded)) {
|
||
try { decoded = decodeURI(new URL(decoded).pathname || decoded); } catch {}
|
||
} else if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(decoded) && !/^[A-Za-z]:[\\/]/.test(decoded)) {
|
||
return null;
|
||
}
|
||
|
||
let line = 0;
|
||
let hasLineSuffix = false;
|
||
const hashMatch = decoded.match(/#L?(\d+)(?:\b|$)/i);
|
||
if (hashMatch) {
|
||
line = Number(hashMatch[1]) || 0;
|
||
decoded = decoded.slice(0, hashMatch.index);
|
||
}
|
||
|
||
decoded = decoded.replace(/\\/g, '/');
|
||
const lineMatch = decoded.match(/^(.+?):(\d+)(?::\d+)?$/);
|
||
if (lineMatch) {
|
||
decoded = lineMatch[1];
|
||
line = Number(lineMatch[2]) || line;
|
||
hasLineSuffix = true;
|
||
}
|
||
|
||
const filePath = normalizeAbsoluteDisplayPath(decoded);
|
||
if (!filePath) return null;
|
||
if (!isAbsoluteDisplayPath(filePath) && !/^\.{1,2}\//.test(filePath) && !filePath.includes('/') && !hasLineSuffix) return null;
|
||
|
||
return { filePath, line };
|
||
}
|
||
|
||
function formatLocalFileLinkText(link, fallbackText) {
|
||
const rawText = String(fallbackText || '').trim();
|
||
const baseText = rawText && rawText !== link.filePath ? rawText : (getPathLeaf(link.filePath) || link.filePath);
|
||
if (!link.line || new RegExp(`:${link.line}$`).test(baseText)) return baseText;
|
||
return `${baseText}:${link.line}`;
|
||
}
|
||
|
||
async function fetchAuthJson(url, options = {}) {
|
||
await ensureAuthenticatedWs();
|
||
const response = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
...(options.headers || {}),
|
||
Authorization: `Bearer ${authToken}`,
|
||
},
|
||
});
|
||
const rawText = await response.text();
|
||
let data = null;
|
||
try {
|
||
data = rawText ? JSON.parse(rawText) : null;
|
||
} catch {
|
||
data = null;
|
||
}
|
||
if (response.status === 401) {
|
||
throw new Error('登录状态已失效,请刷新页面后重新登录。');
|
||
}
|
||
if (!response.ok || data?.ok === false) {
|
||
throw new Error(data?.message || `请求失败 (${response.status})`);
|
||
}
|
||
return data || {};
|
||
}
|
||
|
||
function updateReloadMcpButtonUI() {
|
||
if (!reloadMcpBtn) return;
|
||
const visible = !!currentSessionId && isCodexAppAgent(currentAgent);
|
||
reloadMcpBtn.hidden = !visible;
|
||
reloadMcpBtn.disabled = !visible || isReloadingMcp;
|
||
reloadMcpBtn.textContent = isReloadingMcp ? '重载中' : '重载 MCP';
|
||
reloadMcpBtn.setAttribute('aria-busy', isReloadingMcp ? 'true' : 'false');
|
||
}
|
||
|
||
function normalizeMcpStartupStatusPayload(payload) {
|
||
if (!payload || typeof payload !== 'object') return null;
|
||
if (payload.mcpStatus && typeof payload.mcpStatus === 'object') return payload.mcpStatus;
|
||
if (payload.status && typeof payload.status === 'object') return payload.status;
|
||
return payload;
|
||
}
|
||
|
||
function mcpStartupStatusToastText(status) {
|
||
const summary = normalizeMcpStartupStatusPayload(status);
|
||
if (!summary) return '已请求重载,等待状态';
|
||
const server = String(summary.server || summary.name || 'ccweb').trim() || 'ccweb';
|
||
const state = String(summary.status || 'unknown').trim().toLowerCase();
|
||
const message = String(summary.message || '').trim();
|
||
if (state === 'ready') return `${server} MCP 已启动`;
|
||
if (state === 'failed') return `${server} MCP 启动失败${message ? `:${message}` : ''}`;
|
||
if (state === 'cancelled' || state === 'canceled') return `${server} MCP 启动已取消${message ? `:${message}` : ''}`;
|
||
if (state === 'starting') return `${server} MCP 正在启动`;
|
||
if (state === 'pending' || state === 'unknown') return '已请求重载,等待状态';
|
||
return `${server} MCP 状态:${state}`;
|
||
}
|
||
|
||
function rememberMcpStartupStatus(sessionId, status) {
|
||
const summary = normalizeMcpStartupStatusPayload(status);
|
||
if (!sessionId || !summary) return;
|
||
updateCachedSession(sessionId, (snapshot) => {
|
||
snapshot.codexAppMcpStartupStatus = deepClone(summary);
|
||
});
|
||
}
|
||
|
||
function showMcpStartupStatusToast(status, sessionId = currentSessionId, options = {}) {
|
||
const summary = normalizeMcpStartupStatusPayload(status);
|
||
const text = mcpStartupStatusToastText(summary);
|
||
const server = String(summary?.server || summary?.name || 'ccweb').trim() || 'ccweb';
|
||
const state = String(summary?.status || 'pending').trim().toLowerCase() || 'pending';
|
||
if (state === 'ready' && !options.notifyReady) return;
|
||
if ((state === 'starting' || state === 'pending' || state === 'unknown') && !options.notifyPending) return;
|
||
const stamp = state === 'failed' || state === 'cancelled' || state === 'canceled'
|
||
? String(summary?.message || text || '')
|
||
: '';
|
||
const cacheKey = sessionId || currentSessionId || 'global';
|
||
const nextKey = `${server}|${state}|${stamp}`;
|
||
if (mcpStartupToastKeys.get(cacheKey) === nextKey) return;
|
||
mcpStartupToastKeys.set(cacheKey, nextKey);
|
||
showToast(text, sessionId);
|
||
}
|
||
|
||
async function reloadCurrentMcpServers() {
|
||
if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return;
|
||
isReloadingMcp = true;
|
||
updateReloadMcpButtonUI();
|
||
try {
|
||
const data = await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, {
|
||
method: 'POST',
|
||
});
|
||
rememberMcpStartupStatus(currentSessionId, data.mcpStatus);
|
||
showMcpStartupStatusToast(data.mcpStatus || { status: 'pending' }, currentSessionId, {
|
||
notifyReady: true,
|
||
notifyPending: true,
|
||
});
|
||
} catch (err) {
|
||
showToast(err?.message || '重载 MCP 失败');
|
||
} finally {
|
||
isReloadingMcp = false;
|
||
updateReloadMcpButtonUI();
|
||
}
|
||
}
|
||
|
||
function closeCodexAppUserInputModal(sendCancel = false) {
|
||
if (!codexAppUserInputModal) return;
|
||
const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal;
|
||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
codexAppUserInputModal = null;
|
||
if (sendCancel && requestId) {
|
||
send({
|
||
type: 'codex_app_user_input_response',
|
||
action: 'cancel',
|
||
sessionId,
|
||
requestId,
|
||
answers: {},
|
||
});
|
||
}
|
||
}
|
||
|
||
function codexAppApprovalPayloadText(payload) {
|
||
if (payload === null || payload === undefined) return '';
|
||
if (typeof payload === 'string') return payload;
|
||
try {
|
||
return JSON.stringify(payload, null, 2);
|
||
} catch {
|
||
return String(payload);
|
||
}
|
||
}
|
||
|
||
function closeCodexAppApprovalModal(sendCancel = false) {
|
||
if (!codexAppApprovalModal) return;
|
||
const { overlay, escapeHandler, requestId, sessionId } = codexAppApprovalModal;
|
||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
codexAppApprovalModal = null;
|
||
if (sendCancel && requestId) {
|
||
send({
|
||
type: 'codex_app_approval_response',
|
||
action: 'cancel',
|
||
sessionId,
|
||
requestId,
|
||
});
|
||
}
|
||
}
|
||
|
||
function submitCodexAppApproval(action) {
|
||
if (!codexAppApprovalModal) return;
|
||
const { requestId, sessionId } = codexAppApprovalModal;
|
||
send({
|
||
type: 'codex_app_approval_response',
|
||
action,
|
||
sessionId,
|
||
requestId,
|
||
});
|
||
closeCodexAppApprovalModal(false);
|
||
}
|
||
|
||
function showCodexAppApprovalModal(msg) {
|
||
closeCodexAppApprovalModal(true);
|
||
const payloadText = codexAppApprovalPayloadText(msg.payload);
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay codex-approval-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel codex-approval-panel">
|
||
<div class="modal-header">
|
||
<span class="modal-title">${escapeHtml(msg.title || 'Codex App 请求审批')}</span>
|
||
<button class="modal-close-btn" type="button" data-codex-approval-cancel>✕</button>
|
||
</div>
|
||
<div class="modal-body codex-approval-body">
|
||
${msg.summary ? `<div class="codex-approval-summary">${escapeHtml(msg.summary)}</div>` : ''}
|
||
${msg.reason ? `<div class="codex-approval-reason">${escapeHtml(msg.reason)}</div>` : ''}
|
||
<div class="codex-approval-meta">
|
||
<span>${escapeHtml(msg.approvalType || 'request')}</span>
|
||
${msg.itemId ? `<span>${escapeHtml(msg.itemId)}</span>` : ''}
|
||
</div>
|
||
${payloadText ? `<pre class="codex-approval-payload">${escapeHtml(payloadText)}</pre>` : ''}
|
||
</div>
|
||
<div class="modal-footer codex-approval-footer">
|
||
<button class="modal-btn-secondary" type="button" data-codex-approval-cancel>取消</button>
|
||
<button class="modal-btn-secondary codex-approval-deny-btn" type="button" data-codex-approval-action="deny">拒绝</button>
|
||
<button class="modal-btn-primary" type="button" data-codex-approval-action="approve">本次批准</button>
|
||
${msg.allowSessionScope ? '<button class="modal-btn-primary" type="button" data-codex-approval-action="approve_session">本会话批准</button>' : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
const escapeHandler = (e) => {
|
||
if (e.key === 'Escape') closeCodexAppApprovalModal(true);
|
||
};
|
||
document.addEventListener('keydown', escapeHandler);
|
||
|
||
codexAppApprovalModal = {
|
||
overlay,
|
||
requestId: msg.requestId || '',
|
||
sessionId: msg.sessionId || '',
|
||
escapeHandler,
|
||
};
|
||
|
||
overlay.querySelectorAll('[data-codex-approval-cancel]').forEach((button) => {
|
||
button.addEventListener('click', () => closeCodexAppApprovalModal(true));
|
||
});
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) closeCodexAppApprovalModal(true);
|
||
});
|
||
overlay.querySelectorAll('[data-codex-approval-action]').forEach((button) => {
|
||
button.addEventListener('click', () => submitCodexAppApproval(button.dataset.codexApprovalAction || 'cancel'));
|
||
});
|
||
overlay.querySelector('[data-codex-approval-action="approve"]')?.focus();
|
||
}
|
||
|
||
function cssEscape(value) {
|
||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
|
||
return String(value || '').replace(/["\\]/g, '\\$&');
|
||
}
|
||
|
||
function collectCodexAppUserInputAnswers(panel, questions) {
|
||
const answers = {};
|
||
for (const question of questions) {
|
||
const id = String(question?.id || '').trim();
|
||
if (!id) continue;
|
||
const escapedId = cssEscape(id);
|
||
const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`);
|
||
const values = [];
|
||
if (checked) {
|
||
if (checked.value === '__other__') {
|
||
const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
|
||
const text = String(input?.value || '').trim();
|
||
if (text) values.push(text);
|
||
} else {
|
||
values.push(checked.value);
|
||
}
|
||
} else {
|
||
const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
|
||
const text = String(input?.value || '').trim();
|
||
if (text) values.push(text);
|
||
}
|
||
answers[id] = { answers: values };
|
||
}
|
||
return answers;
|
||
}
|
||
|
||
function renderCodexAppQuestion(question, index) {
|
||
const id = String(question?.id || `q${index}`);
|
||
const options = Array.isArray(question?.options) ? question.options : [];
|
||
const hasOther = !!question?.isOther || options.length === 0;
|
||
const inputType = question?.isSecret ? 'password' : 'text';
|
||
const optionHtml = options.map((option, optionIndex) => {
|
||
const value = String(option?.label || `选项 ${optionIndex + 1}`);
|
||
return `
|
||
<label class="codex-user-input-option">
|
||
<input type="radio" name="codex-ui-${escapeHtml(id)}" value="${escapeHtml(value)}"${optionIndex === 0 && !hasOther ? ' checked' : ''}>
|
||
<span class="codex-user-input-option-copy">
|
||
<span class="codex-user-input-option-label">${escapeHtml(value)}</span>
|
||
${option?.description ? `<span class="codex-user-input-option-desc">${escapeHtml(option.description)}</span>` : ''}
|
||
</span>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
const otherHtml = hasOther ? `
|
||
<label class="codex-user-input-option codex-user-input-other-option">
|
||
${options.length > 0 ? `<input type="radio" name="codex-ui-${escapeHtml(id)}" value="__other__">` : ''}
|
||
<span class="codex-user-input-option-copy">
|
||
<span class="codex-user-input-option-label">${options.length > 0 ? '其他' : '回答'}</span>
|
||
<input class="codex-user-input-text" type="${inputType}" data-codex-ui-other="${escapeHtml(id)}" autocomplete="off">
|
||
</span>
|
||
</label>
|
||
` : '';
|
||
|
||
return `
|
||
<section class="codex-user-input-question">
|
||
<div class="codex-user-input-kicker">${escapeHtml(question?.header || `问题 ${index + 1}`)}</div>
|
||
<div class="codex-user-input-prompt">${escapeHtml(question?.question || '请选择一个答案。')}</div>
|
||
<div class="codex-user-input-options">
|
||
${optionHtml}
|
||
${otherHtml}
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function showCodexAppUserInputModal(msg) {
|
||
closeCodexAppUserInputModal(true);
|
||
const questions = Array.isArray(msg.questions) ? msg.questions : [];
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay codex-user-input-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel codex-user-input-panel">
|
||
<div class="modal-header">
|
||
<span class="modal-title">Codex App 需要输入</span>
|
||
<button class="modal-close-btn" type="button" data-codex-ui-cancel>✕</button>
|
||
</div>
|
||
<div class="modal-body codex-user-input-body">
|
||
${questions.length > 0
|
||
? questions.map((question, index) => renderCodexAppQuestion(question, index)).join('')
|
||
: '<div class="modal-empty">Codex App 没有提供可回答的问题。</div>'}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-btn-secondary" type="button" data-codex-ui-cancel>取消</button>
|
||
<button class="modal-btn-primary" type="button" data-codex-ui-submit>提交</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
const panel = overlay.querySelector('.codex-user-input-panel');
|
||
const escapeHandler = (e) => {
|
||
if (e.key === 'Escape') closeCodexAppUserInputModal(true);
|
||
};
|
||
document.addEventListener('keydown', escapeHandler);
|
||
|
||
codexAppUserInputModal = {
|
||
overlay,
|
||
requestId: msg.requestId || '',
|
||
sessionId: msg.sessionId || '',
|
||
escapeHandler,
|
||
};
|
||
|
||
overlay.querySelectorAll('[data-codex-ui-cancel]').forEach((button) => {
|
||
button.addEventListener('click', () => closeCodexAppUserInputModal(true));
|
||
});
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) closeCodexAppUserInputModal(true);
|
||
});
|
||
overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => {
|
||
send({
|
||
type: 'codex_app_user_input_response',
|
||
action: 'submit',
|
||
sessionId: msg.sessionId,
|
||
requestId: msg.requestId,
|
||
answers: collectCodexAppUserInputAnswers(panel, questions),
|
||
});
|
||
closeCodexAppUserInputModal(false);
|
||
});
|
||
|
||
panel.querySelectorAll('.codex-user-input-text').forEach((input) => {
|
||
input.addEventListener('focus', () => {
|
||
const radio = input.closest('.codex-user-input-option')?.querySelector('input[type="radio"]');
|
||
if (radio) radio.checked = true;
|
||
});
|
||
});
|
||
panel.querySelector('input, button')?.focus();
|
||
}
|
||
|
||
function ccwebPromptStatusLabel(status) {
|
||
if (status === 'submitted') return '已提交';
|
||
if (status === 'cancelled') return '已取消';
|
||
return '待回答';
|
||
}
|
||
|
||
function ccwebPromptRecommendedOption(question) {
|
||
const options = Array.isArray(question?.options) ? question.options : [];
|
||
return options.find((option) => option?.recommended) || options[0] || null;
|
||
}
|
||
|
||
function getCcwebPromptViewMode() {
|
||
return localStorage.getItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY) === 'tabs' ? 'tabs' : 'cards';
|
||
}
|
||
|
||
function setCcwebPromptActiveQuestion(card, index) {
|
||
if (!card) return;
|
||
const questions = Array.from(card.querySelectorAll('.ccweb-prompt-question'));
|
||
if (questions.length === 0) return;
|
||
const activeIndex = Math.min(Math.max(Number(index) || 0, 0), questions.length - 1);
|
||
card.dataset.activeQuestionIndex = String(activeIndex);
|
||
questions.forEach((questionEl, questionIndex) => {
|
||
const isActive = questionIndex === activeIndex;
|
||
questionEl.classList.toggle('is-active', isActive);
|
||
questionEl.setAttribute('aria-hidden', isActive ? 'false' : 'true');
|
||
});
|
||
card.querySelectorAll('.ccweb-prompt-tab').forEach((tab, tabIndex) => {
|
||
const isActive = tabIndex === activeIndex;
|
||
tab.classList.toggle('is-active', isActive);
|
||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||
tab.tabIndex = isActive ? 0 : -1;
|
||
});
|
||
const counter = card.querySelector('.ccweb-prompt-tab-counter');
|
||
if (counter) counter.textContent = `问题 ${activeIndex + 1} / ${questions.length}`;
|
||
const prev = card.querySelector('[data-ccweb-prompt-prev]');
|
||
const next = card.querySelector('[data-ccweb-prompt-next]');
|
||
if (prev) prev.disabled = activeIndex <= 0;
|
||
if (next) next.disabled = activeIndex >= questions.length - 1;
|
||
}
|
||
|
||
function setCcwebPromptViewMode(card, mode) {
|
||
if (!card) return;
|
||
const normalized = mode === 'tabs' ? 'tabs' : 'cards';
|
||
card.dataset.viewMode = normalized;
|
||
card.querySelectorAll('.ccweb-prompt-view-btn').forEach((button) => {
|
||
const isActive = button.dataset.viewMode === normalized;
|
||
button.classList.toggle('is-active', isActive);
|
||
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||
});
|
||
if (normalized === 'tabs') {
|
||
setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0));
|
||
} else {
|
||
card.querySelectorAll('.ccweb-prompt-question').forEach((questionEl) => {
|
||
questionEl.setAttribute('aria-hidden', 'false');
|
||
});
|
||
}
|
||
}
|
||
|
||
function createCcwebPromptViewControls(card, questions) {
|
||
const switcher = document.createElement('div');
|
||
switcher.className = 'ccweb-prompt-view-switcher';
|
||
switcher.setAttribute('aria-label', '表单显示方式');
|
||
[
|
||
{ mode: 'cards', label: '▦', title: '卡片视图' },
|
||
{ mode: 'tabs', label: '▤', title: '页签视图' },
|
||
].forEach((item) => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'ccweb-prompt-view-btn';
|
||
button.dataset.viewMode = item.mode;
|
||
button.textContent = item.label;
|
||
button.title = item.title;
|
||
button.setAttribute('aria-label', item.title);
|
||
button.addEventListener('click', () => {
|
||
localStorage.setItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY, item.mode);
|
||
setCcwebPromptViewMode(card, item.mode);
|
||
});
|
||
switcher.appendChild(button);
|
||
});
|
||
return switcher;
|
||
}
|
||
|
||
function createCcwebPromptTabs(card, questions) {
|
||
const controls = document.createElement('div');
|
||
controls.className = 'ccweb-prompt-view-controls';
|
||
|
||
const tabs = document.createElement('div');
|
||
tabs.className = 'ccweb-prompt-tabs';
|
||
tabs.setAttribute('role', 'tablist');
|
||
questions.forEach((question, index) => {
|
||
const tab = document.createElement('button');
|
||
tab.type = 'button';
|
||
tab.className = 'ccweb-prompt-tab';
|
||
tab.setAttribute('role', 'tab');
|
||
tab.textContent = question.title || `问题 ${index + 1}`;
|
||
tab.addEventListener('click', () => setCcwebPromptActiveQuestion(card, index));
|
||
tabs.appendChild(tab);
|
||
});
|
||
|
||
controls.append(tabs);
|
||
return controls;
|
||
}
|
||
|
||
function createCcwebPromptTabNav(card, questions) {
|
||
const nav = document.createElement('div');
|
||
nav.className = 'ccweb-prompt-tab-nav';
|
||
if (!Array.isArray(questions) || questions.length <= 1) return nav;
|
||
const prev = document.createElement('button');
|
||
prev.type = 'button';
|
||
prev.className = 'ccweb-prompt-tab-nav-btn';
|
||
prev.dataset.ccwebPromptPrev = '1';
|
||
prev.textContent = '上一个';
|
||
prev.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) - 1));
|
||
const counter = document.createElement('span');
|
||
counter.className = 'ccweb-prompt-tab-counter';
|
||
const next = document.createElement('button');
|
||
next.type = 'button';
|
||
next.className = 'ccweb-prompt-tab-nav-btn';
|
||
next.dataset.ccwebPromptNext = '1';
|
||
next.textContent = '下一个';
|
||
next.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) + 1));
|
||
nav.append(prev, counter, next);
|
||
return nav;
|
||
}
|
||
|
||
function setCcwebPromptError(card, message) {
|
||
const error = card.querySelector('.ccweb-prompt-error');
|
||
if (!error) return;
|
||
error.textContent = message || '';
|
||
error.hidden = !message;
|
||
}
|
||
|
||
function updateCcwebPromptAnswerFromSelection(questionEl, question) {
|
||
const textarea = questionEl.querySelector('.ccweb-prompt-answer');
|
||
if (!textarea) return;
|
||
const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
|
||
.map((button) => button.dataset.optionId || '')
|
||
.filter(Boolean);
|
||
const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id));
|
||
const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n');
|
||
if (answerText) textarea.value = answerText;
|
||
}
|
||
|
||
function selectCcwebPromptOption(questionEl, question, optionId) {
|
||
const buttons = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option'));
|
||
if (question.selectionMode === 'multi') {
|
||
buttons.forEach((button) => {
|
||
if (button.dataset.optionId === optionId) button.classList.toggle('is-selected');
|
||
});
|
||
} else {
|
||
buttons.forEach((button) => {
|
||
button.classList.toggle('is-selected', button.dataset.optionId === optionId);
|
||
});
|
||
}
|
||
updateCcwebPromptAnswerFromSelection(questionEl, question);
|
||
}
|
||
|
||
function createCcwebPromptQuestionElement(question, index, prompt) {
|
||
const questionEl = document.createElement('section');
|
||
questionEl.className = 'ccweb-prompt-question';
|
||
questionEl.dataset.questionId = question.id || `question_${index + 1}`;
|
||
|
||
const head = document.createElement('div');
|
||
head.className = 'ccweb-prompt-question-head';
|
||
const title = document.createElement('div');
|
||
title.className = 'ccweb-prompt-question-title';
|
||
title.textContent = question.title || `问题 ${index + 1}`;
|
||
head.appendChild(title);
|
||
if (question.required !== false && prompt.status !== 'submitted') {
|
||
const required = document.createElement('span');
|
||
required.className = 'ccweb-prompt-required';
|
||
required.textContent = '必答';
|
||
head.appendChild(required);
|
||
}
|
||
questionEl.appendChild(head);
|
||
|
||
if (question.question) {
|
||
const body = document.createElement('div');
|
||
body.className = 'ccweb-prompt-question-body';
|
||
body.textContent = question.question;
|
||
questionEl.appendChild(body);
|
||
}
|
||
|
||
if (prompt.status === 'submitted') {
|
||
const answer = prompt.answers?.[question.id] || {};
|
||
if (Array.isArray(answer.selectedOptionLabels) && answer.selectedOptionLabels.length > 0) {
|
||
const selected = document.createElement('div');
|
||
selected.className = 'ccweb-prompt-selected-readonly';
|
||
selected.textContent = `选择:${answer.selectedOptionLabels.join(',')}`;
|
||
questionEl.appendChild(selected);
|
||
}
|
||
const answerText = document.createElement('div');
|
||
answerText.className = 'ccweb-prompt-answer-readonly';
|
||
answerText.textContent = answer.answerText || '(未填写答案)';
|
||
questionEl.appendChild(answerText);
|
||
return questionEl;
|
||
}
|
||
|
||
const options = Array.isArray(question.options) ? question.options : [];
|
||
if (options.length > 0 && question.selectionMode !== 'none') {
|
||
const optionList = document.createElement('div');
|
||
optionList.className = 'ccweb-prompt-options';
|
||
options.forEach((option) => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'ccweb-prompt-option';
|
||
button.dataset.optionId = option.id || '';
|
||
const label = document.createElement('span');
|
||
label.className = 'ccweb-prompt-option-label';
|
||
label.textContent = option.label || option.id || '选项';
|
||
button.appendChild(label);
|
||
if (option.recommended) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'ccweb-prompt-option-badge';
|
||
badge.textContent = '推荐';
|
||
button.appendChild(badge);
|
||
}
|
||
if (option.description) {
|
||
const desc = document.createElement('span');
|
||
desc.className = 'ccweb-prompt-option-desc';
|
||
desc.textContent = option.description;
|
||
button.appendChild(desc);
|
||
}
|
||
button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id));
|
||
optionList.appendChild(button);
|
||
});
|
||
questionEl.appendChild(optionList);
|
||
}
|
||
|
||
const answer = document.createElement('textarea');
|
||
answer.className = 'ccweb-prompt-answer';
|
||
answer.rows = 4;
|
||
answer.placeholder = question.answerPlaceholder || '填写你的答案...';
|
||
answer.value = question.defaultAnswer || '';
|
||
questionEl.appendChild(answer);
|
||
|
||
const recommended = ccwebPromptRecommendedOption(question);
|
||
if (recommended?.recommended) {
|
||
selectCcwebPromptOption(questionEl, question, recommended.id);
|
||
}
|
||
|
||
return questionEl;
|
||
}
|
||
|
||
function collectCcwebPromptAnswers(card, prompt) {
|
||
const answers = {};
|
||
for (const question of prompt.questions || []) {
|
||
const escapedId = cssEscape(question.id || '');
|
||
const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${escapedId}"]`);
|
||
if (!questionEl) continue;
|
||
const selectedOptionIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
|
||
.map((button) => button.dataset.optionId || '')
|
||
.filter(Boolean);
|
||
const answerText = String(questionEl.querySelector('.ccweb-prompt-answer')?.value || '').trim();
|
||
if (question.required !== false && !answerText) {
|
||
return { ok: false, message: `请填写「${question.title || question.id}」的答案。` };
|
||
}
|
||
answers[question.id] = { selectedOptionIds, answerText };
|
||
}
|
||
return { ok: true, answers };
|
||
}
|
||
|
||
function createCcwebPromptElement(prompt, meta = {}) {
|
||
const card = document.createElement('section');
|
||
const promptStatus = prompt?.status || 'pending';
|
||
const questions = Array.isArray(prompt?.questions) ? prompt.questions : [];
|
||
card.className = 'ccweb-prompt-card';
|
||
card.dataset.promptId = prompt?.id || '';
|
||
card.dataset.status = promptStatus;
|
||
card.dataset.viewMode = 'cards';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'ccweb-prompt-header';
|
||
const titleWrap = document.createElement('div');
|
||
titleWrap.className = 'ccweb-prompt-title-wrap';
|
||
const title = document.createElement('div');
|
||
title.className = 'ccweb-prompt-title';
|
||
title.textContent = prompt?.title || '需要用户确认';
|
||
titleWrap.appendChild(title);
|
||
header.appendChild(titleWrap);
|
||
const headerActions = document.createElement('div');
|
||
headerActions.className = 'ccweb-prompt-header-actions';
|
||
const status = document.createElement('span');
|
||
status.className = 'ccweb-prompt-status';
|
||
status.textContent = promptStatus === 'pending' ? '●' : ccwebPromptStatusLabel(prompt?.status || 'pending');
|
||
status.title = ccwebPromptStatusLabel(prompt?.status || 'pending');
|
||
status.setAttribute('aria-label', ccwebPromptStatusLabel(prompt?.status || 'pending'));
|
||
headerActions.appendChild(status);
|
||
if (promptStatus === 'pending' && questions.length > 1) {
|
||
headerActions.appendChild(createCcwebPromptViewControls(card, questions));
|
||
}
|
||
header.appendChild(headerActions);
|
||
card.appendChild(header);
|
||
|
||
if (prompt?.description) {
|
||
const desc = document.createElement('div');
|
||
desc.className = 'ccweb-prompt-desc';
|
||
desc.textContent = prompt.description;
|
||
card.appendChild(desc);
|
||
}
|
||
|
||
if (promptStatus === 'pending' && questions.length > 1) {
|
||
card.appendChild(createCcwebPromptTabs(card, questions));
|
||
}
|
||
|
||
const questionsWrap = document.createElement('div');
|
||
questionsWrap.className = 'ccweb-prompt-questions';
|
||
questions.forEach((question, index) => {
|
||
questionsWrap.appendChild(createCcwebPromptQuestionElement(question, index, prompt));
|
||
});
|
||
card.appendChild(questionsWrap);
|
||
|
||
const error = document.createElement('div');
|
||
error.className = 'ccweb-prompt-error';
|
||
error.hidden = true;
|
||
card.appendChild(error);
|
||
|
||
if ((prompt?.status || 'pending') === 'pending') {
|
||
const footer = document.createElement('div');
|
||
footer.className = 'ccweb-prompt-footer';
|
||
if (questions.length > 1) {
|
||
footer.appendChild(createCcwebPromptTabNav(card, questions));
|
||
}
|
||
const footerActions = document.createElement('div');
|
||
footerActions.className = 'ccweb-prompt-footer-actions';
|
||
const fillRecommended = document.createElement('button');
|
||
fillRecommended.type = 'button';
|
||
fillRecommended.className = 'ccweb-prompt-secondary';
|
||
fillRecommended.textContent = '填入推荐';
|
||
fillRecommended.addEventListener('click', () => {
|
||
questions.forEach((question) => {
|
||
const option = ccwebPromptRecommendedOption(question);
|
||
if (!option) return;
|
||
const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${cssEscape(question.id || '')}"]`);
|
||
if (questionEl) selectCcwebPromptOption(questionEl, question, option.id);
|
||
});
|
||
});
|
||
footerActions.appendChild(fillRecommended);
|
||
|
||
const submit = document.createElement('button');
|
||
submit.type = 'button';
|
||
submit.className = 'ccweb-prompt-submit';
|
||
submit.textContent = '提交全部';
|
||
submit.addEventListener('click', () => {
|
||
const collected = collectCcwebPromptAnswers(card, prompt);
|
||
if (!collected.ok) {
|
||
setCcwebPromptError(card, collected.message);
|
||
return;
|
||
}
|
||
setCcwebPromptError(card, '');
|
||
submit.disabled = true;
|
||
submit.textContent = '提交中';
|
||
send({
|
||
type: 'ccweb_prompt_user_response',
|
||
sessionId: meta.sessionId || currentSessionId,
|
||
promptId: prompt.id,
|
||
answers: collected.answers,
|
||
});
|
||
});
|
||
footerActions.appendChild(submit);
|
||
footer.appendChild(footerActions);
|
||
card.appendChild(footer);
|
||
}
|
||
|
||
if (promptStatus === 'pending' && questions.length > 1) {
|
||
setCcwebPromptActiveQuestion(card, 0);
|
||
setCcwebPromptViewMode(card, getCcwebPromptViewMode());
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
function updateCcwebPromptMessageInSnapshot(snapshot, prompt) {
|
||
if (!snapshot || !Array.isArray(snapshot.messages) || !prompt?.id) return;
|
||
for (const message of snapshot.messages) {
|
||
if (message?.ccwebPrompt?.id === prompt.id) {
|
||
message.ccwebPrompt = deepClone(prompt);
|
||
}
|
||
}
|
||
}
|
||
|
||
function removeCcwebPromptMessageFromSnapshot(snapshot, promptId) {
|
||
if (!snapshot || !Array.isArray(snapshot.messages) || !promptId) return;
|
||
snapshot.messages = snapshot.messages.filter((message) => message?.ccwebPrompt?.id !== promptId);
|
||
}
|
||
|
||
function removeCcwebPromptMessageFromDom(promptId) {
|
||
if (!promptId) return 0;
|
||
let removed = 0;
|
||
document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`).forEach((card) => {
|
||
const messageEl = card.closest('.msg');
|
||
if (messageEl?.parentNode) {
|
||
messageEl.remove();
|
||
} else {
|
||
card.remove();
|
||
}
|
||
removed += 1;
|
||
});
|
||
if (removed > 0) {
|
||
updateUserOutlinePanel();
|
||
updateScrollbar();
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
function applyCcwebPromptUserUpdate(msg) {
|
||
if (msg.sessionId && msg.prompt) {
|
||
updateCachedSession(msg.sessionId, (snapshot) => updateCcwebPromptMessageInSnapshot(snapshot, msg.prompt));
|
||
}
|
||
if (msg.sessionId !== currentSessionId || !msg.prompt?.id) return;
|
||
document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(msg.prompt.id)}"]`).forEach((card) => {
|
||
card.replaceWith(createCcwebPromptElement(msg.prompt, { sessionId: msg.sessionId }));
|
||
});
|
||
renderPendingCcwebPrompts({ scroll: false });
|
||
}
|
||
|
||
function applyCcwebPromptUserRemove(msg) {
|
||
const promptId = msg.promptId || msg.prompt?.id || '';
|
||
if (msg.sessionId && promptId) {
|
||
updateCachedSession(msg.sessionId, (snapshot) => removeCcwebPromptMessageFromSnapshot(snapshot, promptId));
|
||
}
|
||
if (msg.sessionId !== currentSessionId || !promptId) return;
|
||
removeCcwebPromptMessageFromDom(promptId);
|
||
renderPendingCcwebPrompts({ scroll: false });
|
||
}
|
||
|
||
function closeDirectoryPicker() {
|
||
if (!directoryPickerState) return;
|
||
const { overlay, escapeHandler } = directoryPickerState;
|
||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
directoryPickerState = null;
|
||
}
|
||
|
||
function setDirectoryPickerStatus(message, type = '') {
|
||
if (!directoryPickerState?.statusEl) return;
|
||
directoryPickerState.statusEl.textContent = message || '';
|
||
directoryPickerState.statusEl.dataset.state = type || '';
|
||
}
|
||
|
||
function updateDirectoryPickerPathBar() {
|
||
if (!directoryPickerState?.pathEl) return;
|
||
const displayPath = directoryPickerState.currentPath || directoryPickerState.defaultPath || '';
|
||
directoryPickerState.pathEl.textContent = displayPath;
|
||
directoryPickerState.pathEl.title = displayPath;
|
||
directoryPickerState.upBtn.disabled = !directoryPickerState.parentPath;
|
||
directoryPickerState.chooseBtn.disabled = !displayPath;
|
||
}
|
||
|
||
function renderDirectoryPickerEntries(entries) {
|
||
if (!directoryPickerState?.listEl) return;
|
||
const safeEntries = Array.isArray(entries) ? entries : [];
|
||
if (safeEntries.length === 0) {
|
||
directoryPickerState.listEl.innerHTML = '<div class="modal-empty">这个目录里没有可进入的子目录,直接使用当前目录也可以。</div>';
|
||
return;
|
||
}
|
||
|
||
directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => {
|
||
const metaParts = [entry.symlink ? '链接目录' : '目录'];
|
||
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
|
||
return `
|
||
<button
|
||
class="file-browser-item directory"
|
||
type="button"
|
||
data-path="${escapeHtml(entry.path || '')}"
|
||
>
|
||
<span class="file-browser-item-icon" aria-hidden="true">DIR</span>
|
||
<span class="file-browser-item-copy">
|
||
<span class="file-browser-item-name">${escapeHtml(entry.name || getPathLeaf(entry.path) || '')}</span>
|
||
<span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
|
||
</span>
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
|
||
directoryPickerState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const targetPath = button.dataset.path || '';
|
||
if (targetPath) loadDirectoryPickerDirectory(targetPath);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadDirectoryPickerDirectory(targetPath, options = {}) {
|
||
if (!directoryPickerState) return;
|
||
const state = directoryPickerState;
|
||
const requestId = ++state.requestId;
|
||
state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
|
||
setDirectoryPickerStatus('正在读取目录…');
|
||
|
||
try {
|
||
const data = await fetchAuthJson(`/api/fs/directories?path=${encodeURIComponent(targetPath || '')}`);
|
||
if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
|
||
state.currentPath = data.currentPath || state.currentPath;
|
||
state.parentPath = data.parentPath || '';
|
||
state.defaultPath = data.defaultPath || state.defaultPath;
|
||
updateDirectoryPickerPathBar();
|
||
renderDirectoryPickerEntries(data.entries || []);
|
||
const statusParts = [];
|
||
if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`);
|
||
statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 个子目录`);
|
||
setDirectoryPickerStatus(statusParts.join(' · '));
|
||
} catch (err) {
|
||
if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
|
||
if (options.allowFallback !== false && targetPath) {
|
||
loadDirectoryPickerDirectory('', { allowFallback: false });
|
||
return;
|
||
}
|
||
state.listEl.innerHTML = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
|
||
setDirectoryPickerStatus(err.message || '目录读取失败', 'error');
|
||
}
|
||
}
|
||
|
||
function showDirectoryPicker(options = {}) {
|
||
closeDirectoryPicker();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.id = 'directory-picker-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel modal-panel-wide directory-picker-panel">
|
||
<div class="modal-header">
|
||
<span class="modal-title">${escapeHtml(options.title || '选择工作目录')}</span>
|
||
<button class="modal-close-btn" type="button" data-picker-close>✕</button>
|
||
</div>
|
||
<div class="modal-body file-browser-body">
|
||
<div class="file-browser-toolbar">
|
||
<button class="file-browser-toolbar-btn" type="button" data-picker-up>上一级</button>
|
||
<button class="file-browser-toolbar-btn" type="button" data-picker-refresh>刷新</button>
|
||
<div class="file-browser-path" data-picker-path></div>
|
||
</div>
|
||
<div class="file-browser-status" data-picker-status>正在读取目录…</div>
|
||
<div class="file-browser-list directory-picker-list" data-picker-list></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-btn-secondary" type="button" data-picker-cancel>取消</button>
|
||
<button class="modal-btn-primary" type="button" data-picker-choose disabled>使用当前目录</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
directoryPickerState = {
|
||
overlay,
|
||
pathEl: overlay.querySelector('[data-picker-path]'),
|
||
statusEl: overlay.querySelector('[data-picker-status]'),
|
||
listEl: overlay.querySelector('[data-picker-list]'),
|
||
upBtn: overlay.querySelector('[data-picker-up]'),
|
||
refreshBtn: overlay.querySelector('[data-picker-refresh]'),
|
||
chooseBtn: overlay.querySelector('[data-picker-choose]'),
|
||
currentPath: '',
|
||
parentPath: '',
|
||
defaultPath: '',
|
||
requestId: 0,
|
||
onChoose: typeof options.onChoose === 'function' ? options.onChoose : null,
|
||
escapeHandler: null,
|
||
};
|
||
|
||
directoryPickerState.escapeHandler = (e) => {
|
||
if (e.key === 'Escape') closeDirectoryPicker();
|
||
};
|
||
document.addEventListener('keydown', directoryPickerState.escapeHandler);
|
||
|
||
const closeButtons = overlay.querySelectorAll('[data-picker-close], [data-picker-cancel]');
|
||
closeButtons.forEach((button) => button.addEventListener('click', closeDirectoryPicker));
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) closeDirectoryPicker();
|
||
});
|
||
directoryPickerState.upBtn.addEventListener('click', () => {
|
||
if (directoryPickerState?.parentPath) loadDirectoryPickerDirectory(directoryPickerState.parentPath, { allowFallback: false });
|
||
});
|
||
directoryPickerState.refreshBtn.addEventListener('click', () => {
|
||
loadDirectoryPickerDirectory(directoryPickerState?.currentPath || '', { allowFallback: false });
|
||
});
|
||
directoryPickerState.chooseBtn.addEventListener('click', () => {
|
||
const selectedPath = directoryPickerState?.currentPath || directoryPickerState?.defaultPath || '';
|
||
const onChoose = directoryPickerState?.onChoose;
|
||
closeDirectoryPicker();
|
||
if (selectedPath && typeof onChoose === 'function') onChoose(selectedPath);
|
||
});
|
||
|
||
updateDirectoryPickerPathBar();
|
||
loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true });
|
||
}
|
||
|
||
function closeFileBrowser() {
|
||
if (!fileBrowserState) return;
|
||
const { overlay, escapeHandler } = fileBrowserState;
|
||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
fileBrowserState = null;
|
||
}
|
||
|
||
function setFileBrowserStatus(message, type = '') {
|
||
if (!fileBrowserState?.statusEl) return;
|
||
fileBrowserState.statusEl.textContent = message || '';
|
||
fileBrowserState.statusEl.dataset.state = type || '';
|
||
}
|
||
|
||
function setFileBrowserPreviewMode(active) {
|
||
if (!fileBrowserState?.panel) return;
|
||
fileBrowserState.panel.classList.toggle('preview-active', !!active);
|
||
}
|
||
|
||
function syncFileBrowserSelection() {
|
||
if (!fileBrowserState?.listEl) return;
|
||
fileBrowserState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
|
||
button.classList.toggle(
|
||
'active',
|
||
button.dataset.kind === 'file' && button.dataset.path === fileBrowserState.selectedFilePath
|
||
);
|
||
});
|
||
}
|
||
|
||
function renderFileBrowserPreviewEmpty(title, message) {
|
||
if (!fileBrowserState) return;
|
||
fileBrowserState.previewTitleEl.textContent = title || '文件预览';
|
||
fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容';
|
||
fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容';
|
||
fileBrowserState.previewEmptyEl.hidden = false;
|
||
fileBrowserState.previewCodeEl.hidden = true;
|
||
fileBrowserState.previewCodeEl.textContent = '';
|
||
}
|
||
|
||
function renderFileBrowserPreviewLoading(name) {
|
||
if (!fileBrowserState) return;
|
||
fileBrowserState.previewTitleEl.textContent = name || '文件预览';
|
||
fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…';
|
||
fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…';
|
||
fileBrowserState.previewEmptyEl.hidden = false;
|
||
fileBrowserState.previewCodeEl.hidden = true;
|
||
fileBrowserState.previewCodeEl.textContent = '';
|
||
}
|
||
|
||
function updateFileBrowserPathBar() {
|
||
if (!fileBrowserState) return;
|
||
const displayPath = getBrowserDisplayPath(fileBrowserState.rootPath, fileBrowserState.currentPath);
|
||
fileBrowserState.pathEl.textContent = displayPath;
|
||
fileBrowserState.pathEl.title = displayPath;
|
||
fileBrowserState.upBtn.disabled = !fileBrowserState.currentPath;
|
||
}
|
||
|
||
function renderFileBrowserDirectory(entries) {
|
||
if (!fileBrowserState) return;
|
||
const state = fileBrowserState;
|
||
const safeEntries = Array.isArray(entries) ? entries : [];
|
||
|
||
if (safeEntries.length === 0) {
|
||
state.listEl.innerHTML = '<div class="modal-empty">这个目录里还没有可显示的文件</div>';
|
||
return;
|
||
}
|
||
|
||
state.listEl.innerHTML = safeEntries.map((entry) => {
|
||
const metaParts = [];
|
||
if (entry.kind === 'directory') {
|
||
metaParts.push(entry.symlink ? '链接目录' : '目录');
|
||
} else {
|
||
metaParts.push(entry.previewableHint ? '文本' : '文件');
|
||
if (entry.size >= 0) metaParts.push(formatFileSize(entry.size));
|
||
}
|
||
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
|
||
return `
|
||
<button
|
||
class="file-browser-item${entry.kind === 'directory' ? ' directory' : ''}"
|
||
type="button"
|
||
data-kind="${escapeHtml(entry.kind)}"
|
||
data-path="${escapeHtml(entry.path || '')}"
|
||
>
|
||
<span class="file-browser-item-icon" aria-hidden="true">${entry.kind === 'directory' ? 'DIR' : (entry.previewableHint ? 'TXT' : 'FILE')}</span>
|
||
<span class="file-browser-item-copy">
|
||
<span class="file-browser-item-name">${escapeHtml(entry.name || '')}</span>
|
||
<span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
|
||
</span>
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
|
||
state.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const itemPath = normalizeBrowserPath(button.dataset.path || '');
|
||
if (button.dataset.kind === 'directory') {
|
||
loadFileBrowserDirectory(itemPath);
|
||
return;
|
||
}
|
||
openFileBrowserFile(itemPath);
|
||
});
|
||
});
|
||
|
||
syncFileBrowserSelection();
|
||
}
|
||
|
||
async function loadFileBrowserDirectory(targetPath, options = {}) {
|
||
if (!fileBrowserState) return;
|
||
const state = fileBrowserState;
|
||
const normalizedPath = normalizeBrowserPath(targetPath);
|
||
const previousPath = state.currentPath;
|
||
const requestId = ++state.directoryRequestId;
|
||
state.currentPath = normalizedPath;
|
||
updateFileBrowserPathBar();
|
||
state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
|
||
setFileBrowserStatus('正在读取目录…');
|
||
if (!options.preservePreview) {
|
||
state.selectedFilePath = '';
|
||
syncFileBrowserSelection();
|
||
renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
|
||
setFileBrowserPreviewMode(false);
|
||
}
|
||
|
||
try {
|
||
const data = await fetchAuthJson(`/api/fs/list?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
|
||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
|
||
state.rootPath = data.rootPath || state.rootPath;
|
||
state.currentPath = normalizeBrowserPath(data.currentPath || '');
|
||
updateFileBrowserPathBar();
|
||
renderFileBrowserDirectory(data.entries || []);
|
||
const statusParts = [];
|
||
if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`);
|
||
statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 项`);
|
||
setFileBrowserStatus(statusParts.join(' · '));
|
||
} catch (err) {
|
||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
|
||
state.currentPath = previousPath;
|
||
updateFileBrowserPathBar();
|
||
state.listEl.innerHTML = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
|
||
setFileBrowserStatus(err.message || '目录读取失败', 'error');
|
||
}
|
||
}
|
||
|
||
function scrollFileBrowserPreviewToLine(lineNumber) {
|
||
const line = Number(lineNumber || 0);
|
||
const codeEl = fileBrowserState?.previewCodeEl;
|
||
if (!codeEl || line <= 0) return;
|
||
requestAnimationFrame(() => {
|
||
const styles = window.getComputedStyle(codeEl);
|
||
const lineHeight = parseFloat(styles.lineHeight) || (parseFloat(styles.fontSize) * 1.4) || 18;
|
||
codeEl.scrollTop = Math.max(0, (line - 4) * lineHeight);
|
||
});
|
||
}
|
||
|
||
async function openFileBrowserFile(targetPath, options = {}) {
|
||
if (!fileBrowserState) return;
|
||
const state = fileBrowserState;
|
||
const normalizedPath = normalizeBrowserPath(targetPath);
|
||
const targetLine = Math.max(0, Number(options.line || 0) || 0);
|
||
const requestId = ++state.previewRequestId;
|
||
state.selectedFilePath = normalizedPath;
|
||
syncFileBrowserSelection();
|
||
renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览');
|
||
setFileBrowserPreviewMode(true);
|
||
|
||
try {
|
||
const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
|
||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
|
||
state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath);
|
||
syncFileBrowserSelection();
|
||
state.previewTitleEl.textContent = data.name || '文件预览';
|
||
const metaParts = [formatFileSize(data.size || 0)];
|
||
if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt));
|
||
if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`);
|
||
if (targetLine) metaParts.push(`第 ${targetLine} 行`);
|
||
state.previewMetaEl.textContent = metaParts.join(' · ');
|
||
state.previewEmptyEl.hidden = true;
|
||
state.previewCodeEl.hidden = false;
|
||
state.previewCodeEl.textContent = data.content || '';
|
||
scrollFileBrowserPreviewToLine(targetLine);
|
||
setFileBrowserStatus(`已打开 ${data.name || '文件'}${targetLine ? `:${targetLine}` : ''}`);
|
||
} catch (err) {
|
||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
|
||
state.selectedFilePath = '';
|
||
syncFileBrowserSelection();
|
||
renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开');
|
||
setFileBrowserStatus(err.message || '当前文件无法打开', 'error');
|
||
}
|
||
}
|
||
|
||
function showFileBrowser() {
|
||
if (!currentSessionId) {
|
||
showToast('请先打开一个会话');
|
||
return;
|
||
}
|
||
if (!currentCwd) {
|
||
showToast('当前会话没有可浏览的工作目录');
|
||
return;
|
||
}
|
||
if (fileBrowserState && fileBrowserState.sessionId === currentSessionId) return;
|
||
|
||
closeFileBrowser();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.id = 'file-browser-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel modal-panel-wide file-browser-panel">
|
||
<div class="modal-header">
|
||
<span class="modal-title">文件浏览器</span>
|
||
<button class="modal-close-btn" type="button" data-browser-close>✕</button>
|
||
</div>
|
||
<div class="modal-body file-browser-body">
|
||
<div class="file-browser-toolbar">
|
||
<button class="file-browser-toolbar-btn" type="button" data-browser-up>上一级</button>
|
||
<button class="file-browser-toolbar-btn" type="button" data-browser-refresh>刷新</button>
|
||
<div class="file-browser-path" data-browser-path></div>
|
||
</div>
|
||
<div class="file-browser-status" data-browser-status>正在准备目录…</div>
|
||
<div class="file-browser-layout">
|
||
<section class="file-browser-pane file-browser-list-pane">
|
||
<div class="file-browser-pane-title">目录与文件</div>
|
||
<div class="file-browser-list" data-browser-list></div>
|
||
</section>
|
||
<section class="file-browser-pane file-browser-preview-pane">
|
||
<div class="file-browser-preview-header">
|
||
<button class="file-browser-mobile-back" type="button" data-browser-back>返回目录</button>
|
||
<div class="file-browser-preview-copy">
|
||
<div class="file-browser-preview-title" data-browser-preview-title>文件预览</div>
|
||
<div class="file-browser-preview-meta" data-browser-preview-meta>选择一个文本文件查看内容</div>
|
||
</div>
|
||
</div>
|
||
<div class="file-browser-preview-empty" data-browser-preview-empty>选择一个文本文件查看内容</div>
|
||
<pre class="file-browser-preview-content" data-browser-preview-content hidden></pre>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const state = {
|
||
sessionId: currentSessionId,
|
||
rootPath: currentCwd,
|
||
currentPath: '',
|
||
selectedFilePath: '',
|
||
directoryRequestId: 0,
|
||
previewRequestId: 0,
|
||
overlay,
|
||
panel: overlay.querySelector('.file-browser-panel'),
|
||
pathEl: overlay.querySelector('[data-browser-path]'),
|
||
statusEl: overlay.querySelector('[data-browser-status]'),
|
||
listEl: overlay.querySelector('[data-browser-list]'),
|
||
previewTitleEl: overlay.querySelector('[data-browser-preview-title]'),
|
||
previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'),
|
||
previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'),
|
||
previewCodeEl: overlay.querySelector('[data-browser-preview-content]'),
|
||
upBtn: overlay.querySelector('[data-browser-up]'),
|
||
refreshBtn: overlay.querySelector('[data-browser-refresh]'),
|
||
mobileBackBtn: overlay.querySelector('[data-browser-back]'),
|
||
escapeHandler: null,
|
||
};
|
||
fileBrowserState = state;
|
||
|
||
state.escapeHandler = (e) => {
|
||
if (e.key === 'Escape') closeFileBrowser();
|
||
};
|
||
document.addEventListener('keydown', state.escapeHandler);
|
||
|
||
overlay.querySelector('[data-browser-close]').addEventListener('click', closeFileBrowser);
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) closeFileBrowser();
|
||
});
|
||
state.upBtn.addEventListener('click', () => {
|
||
loadFileBrowserDirectory(getBrowserParentPath(state.currentPath));
|
||
});
|
||
state.refreshBtn.addEventListener('click', () => {
|
||
loadFileBrowserDirectory(state.currentPath, { preservePreview: !!state.selectedFilePath });
|
||
});
|
||
state.mobileBackBtn.addEventListener('click', () => {
|
||
setFileBrowserPreviewMode(false);
|
||
});
|
||
|
||
updateFileBrowserPathBar();
|
||
renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
|
||
loadFileBrowserDirectory('');
|
||
}
|
||
|
||
function syncAttachmentActions() {
|
||
const uploading = uploadingAttachments.length > 0;
|
||
if (attachBtn) attachBtn.disabled = uploading;
|
||
}
|
||
|
||
function replaceFileExtension(filename, ext) {
|
||
const base = String(filename || 'image').replace(/\.[^/.]+$/, '');
|
||
return `${base}${ext}`;
|
||
}
|
||
|
||
function loadImageFromFile(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
resolve(img);
|
||
};
|
||
img.onerror = () => {
|
||
URL.revokeObjectURL(url);
|
||
reject(new Error('读取图片失败'));
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
async function compressImageFile(file) {
|
||
if (!file || !/^image\/(png|jpeg|webp)$/i.test(file.type || '')) return file;
|
||
const img = await loadImageFromFile(file);
|
||
const maxDimension = 2000;
|
||
const maxOriginalBytes = 2 * 1024 * 1024;
|
||
const largestSide = Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height);
|
||
if (file.size <= maxOriginalBytes && largestSide <= maxDimension) {
|
||
return file;
|
||
}
|
||
|
||
const scale = Math.min(1, maxDimension / largestSide);
|
||
const width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale));
|
||
const height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale));
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d', { alpha: true });
|
||
if (!ctx) return file;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
const targetType = 'image/webp';
|
||
const qualities = [0.9, 0.84, 0.78, 0.72];
|
||
let bestBlob = null;
|
||
for (const quality of qualities) {
|
||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, targetType, quality));
|
||
if (!blob) continue;
|
||
if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob;
|
||
if (blob.size <= Math.max(maxOriginalBytes, file.size * 0.72)) break;
|
||
}
|
||
if (!bestBlob || bestBlob.size >= file.size) return file;
|
||
return new File([bestBlob], replaceFileExtension(file.name || 'image', '.webp'), {
|
||
type: bestBlob.type,
|
||
lastModified: Date.now(),
|
||
});
|
||
}
|
||
|
||
async function deleteUploadedAttachment(id) {
|
||
if (!id) return;
|
||
try {
|
||
await ensureAuthenticatedWs();
|
||
await fetch(`/api/attachments/${encodeURIComponent(id)}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
Authorization: `Bearer ${authToken}`,
|
||
},
|
||
});
|
||
} catch {}
|
||
clearAttachmentPreviewCache(id);
|
||
}
|
||
|
||
function ensureAuthenticatedWs() {
|
||
return new Promise((resolve, reject) => {
|
||
if (ws && ws.readyState === 1 && authToken) {
|
||
resolve(authToken);
|
||
return;
|
||
}
|
||
const savedPassword = localStorage.getItem('cc-web-pw');
|
||
if (!savedPassword) {
|
||
reject(new Error('登录状态已失效,请刷新页面后重新登录再上传图片。'));
|
||
return;
|
||
}
|
||
const timeout = setTimeout(() => {
|
||
reject(new Error('登录状态恢复超时,请刷新页面后重试。'));
|
||
}, 8000);
|
||
|
||
const cleanup = () => {
|
||
clearTimeout(timeout);
|
||
document.removeEventListener('cc-web-auth-restored', onRestored);
|
||
document.removeEventListener('cc-web-auth-failed', onFailed);
|
||
};
|
||
const onRestored = () => {
|
||
cleanup();
|
||
resolve(authToken);
|
||
};
|
||
const onFailed = () => {
|
||
cleanup();
|
||
reject(new Error('登录状态已失效,请刷新页面后重新登录再上传图片。'));
|
||
};
|
||
document.addEventListener('cc-web-auth-restored', onRestored);
|
||
document.addEventListener('cc-web-auth-failed', onFailed);
|
||
|
||
if (!ws || ws.readyState > 1) {
|
||
connect();
|
||
} else if (ws.readyState === 1) {
|
||
send({ type: 'auth', password: savedPassword });
|
||
}
|
||
});
|
||
}
|
||
|
||
function clearAttachmentPreviewCache(id) {
|
||
const entry = attachmentPreviewCache.get(id);
|
||
if (entry?.url && entry.objectUrl) URL.revokeObjectURL(entry.url);
|
||
attachmentPreviewCache.delete(id);
|
||
}
|
||
|
||
async function getAttachmentPreviewUrl(attachment) {
|
||
const id = String(attachment?.id || '').trim();
|
||
if (!id) throw new Error('图片附件缺少 ID');
|
||
if (attachment.storageState === 'expired') {
|
||
throw new Error('图片已过期');
|
||
}
|
||
if (attachment.previewUrl) return attachment.previewUrl;
|
||
|
||
const cached = attachmentPreviewCache.get(id);
|
||
if (cached?.url) return cached.url;
|
||
if (cached?.promise) return cached.promise;
|
||
|
||
const promise = (async () => {
|
||
const fetchAttachment = async () => {
|
||
await ensureAuthenticatedWs();
|
||
if (!authToken) {
|
||
throw new Error('登录状态已失效,请刷新页面后重新登录再预览图片。');
|
||
}
|
||
return fetch(`/api/attachments/${encodeURIComponent(id)}`, {
|
||
cache: 'no-store',
|
||
headers: {
|
||
Authorization: `Bearer ${authToken}`,
|
||
},
|
||
});
|
||
};
|
||
|
||
let response = await fetchAttachment();
|
||
if (response.status === 401 && localStorage.getItem('cc-web-pw')) {
|
||
authToken = null;
|
||
localStorage.removeItem('cc-web-token');
|
||
response = await fetchAttachment();
|
||
}
|
||
if (response.status === 401) {
|
||
throw new Error('登录状态已失效,请刷新页面后重新登录再预览图片。');
|
||
}
|
||
if (response.status === 404) {
|
||
throw new Error('图片不存在或已过期');
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error('图片预览失败');
|
||
}
|
||
const blob = await response.blob();
|
||
if (!blob || blob.size === 0) {
|
||
throw new Error('图片预览失败');
|
||
}
|
||
const url = URL.createObjectURL(blob);
|
||
attachmentPreviewCache.set(id, { url, objectUrl: true });
|
||
return url;
|
||
})().catch((err) => {
|
||
attachmentPreviewCache.delete(id);
|
||
throw err;
|
||
});
|
||
|
||
attachmentPreviewCache.set(id, { promise });
|
||
return promise;
|
||
}
|
||
|
||
function closeAttachmentPreviewModal() {
|
||
if (!attachmentPreviewModal) return;
|
||
const { overlay, escapeHandler } = attachmentPreviewModal;
|
||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
attachmentPreviewModal = null;
|
||
}
|
||
|
||
async function openAttachmentPreviewModal(attachment) {
|
||
if (!attachment || attachment.storageState === 'expired') {
|
||
showToast('图片已过期,无法预览');
|
||
return;
|
||
}
|
||
|
||
closeAttachmentPreviewModal();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay attachment-preview-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel modal-panel-wide attachment-preview-panel">
|
||
<div class="modal-header">
|
||
<span class="modal-title">图片预览</span>
|
||
<button class="modal-close-btn" type="button" aria-label="关闭">✕</button>
|
||
</div>
|
||
<div class="attachment-preview-body">
|
||
<div class="attachment-preview-stage is-loading">
|
||
<div class="attachment-preview-placeholder">正在加载图片…</div>
|
||
<img class="attachment-preview-image" alt="${escapeHtml(attachment.filename || 'image')}" hidden>
|
||
</div>
|
||
<div class="attachment-preview-meta">
|
||
<div class="attachment-preview-name">${escapeHtml(attachment.filename || 'image')}</div>
|
||
<div class="attachment-preview-desc">${escapeHtml(formatFileSize(attachment.size || 0))} · 点击空白处关闭</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
const stageEl = overlay.querySelector('.attachment-preview-stage');
|
||
const imgEl = overlay.querySelector('.attachment-preview-image');
|
||
const placeholderEl = overlay.querySelector('.attachment-preview-placeholder');
|
||
const closeBtn = overlay.querySelector('.modal-close-btn');
|
||
|
||
const finishClose = () => closeAttachmentPreviewModal();
|
||
attachmentPreviewModal = {
|
||
overlay,
|
||
escapeHandler: null,
|
||
};
|
||
|
||
attachmentPreviewModal.escapeHandler = (e) => {
|
||
if (e.key === 'Escape') finishClose();
|
||
};
|
||
document.addEventListener('keydown', attachmentPreviewModal.escapeHandler);
|
||
|
||
closeBtn.addEventListener('click', finishClose);
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) finishClose();
|
||
});
|
||
|
||
try {
|
||
const url = await getAttachmentPreviewUrl(attachment);
|
||
if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
|
||
imgEl.onload = () => {
|
||
if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
|
||
imgEl.hidden = false;
|
||
placeholderEl.hidden = true;
|
||
stageEl.classList.remove('is-loading');
|
||
stageEl.classList.add('is-ready');
|
||
};
|
||
imgEl.onerror = () => {
|
||
if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
|
||
placeholderEl.textContent = '图片加载失败';
|
||
stageEl.classList.remove('is-loading');
|
||
stageEl.classList.add('is-error');
|
||
};
|
||
imgEl.src = url;
|
||
} catch (err) {
|
||
if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
|
||
placeholderEl.textContent = err.message || '图片预览失败';
|
||
stageEl.classList.remove('is-loading');
|
||
stageEl.classList.add('is-error');
|
||
}
|
||
}
|
||
|
||
function hydrateAttachmentPreviews(root, attachments = []) {
|
||
if (!root) return;
|
||
const attachmentMap = new Map((Array.isArray(attachments) ? attachments : []).map((attachment) => [attachment.id, attachment]));
|
||
root.querySelectorAll('[data-attachment-id]').forEach((node) => {
|
||
const attachment = attachmentMap.get(node.dataset.attachmentId);
|
||
if (!attachment) return;
|
||
const imgEl = node.querySelector('.msg-attachment-thumb-image');
|
||
const placeholderEl = node.querySelector('.msg-attachment-thumb-placeholder');
|
||
const isExpired = attachment.storageState === 'expired';
|
||
|
||
if (!isExpired) {
|
||
getAttachmentPreviewUrl(attachment)
|
||
.then((url) => {
|
||
if (!node.isConnected) return;
|
||
imgEl.onload = () => {
|
||
if (!node.isConnected) return;
|
||
imgEl.hidden = false;
|
||
placeholderEl.hidden = true;
|
||
node.classList.remove('is-error');
|
||
node.classList.add('is-loaded');
|
||
};
|
||
imgEl.onerror = () => {
|
||
if (!node.isConnected) return;
|
||
placeholderEl.textContent = '图片加载失败';
|
||
node.classList.remove('is-loaded');
|
||
node.classList.add('is-error');
|
||
};
|
||
imgEl.src = url;
|
||
})
|
||
.catch((err) => {
|
||
if (!node.isConnected) return;
|
||
placeholderEl.textContent = err.message || '图片加载失败';
|
||
node.classList.add('is-error');
|
||
});
|
||
}
|
||
|
||
node.addEventListener('click', () => openAttachmentPreviewModal(attachment));
|
||
});
|
||
}
|
||
|
||
function renderAttachmentPreviews(attachments, options = {}) {
|
||
if (!Array.isArray(attachments) || attachments.length === 0) return '';
|
||
const items = attachments.map((attachment) => {
|
||
const state = attachment.storageState || 'available';
|
||
const name = escapeHtml(attachment.filename || 'image');
|
||
const size = formatFileSize(attachment.size || 0);
|
||
const isExpired = state === 'expired';
|
||
return `
|
||
<button class="msg-attachment-card${isExpired ? ' is-expired' : ''}" type="button" data-attachment-id="${escapeHtml(attachment.id || '')}" data-attachment-state="${escapeHtml(state)}" data-attachment-name="${name}" ${isExpired ? 'disabled' : ''} title="${isExpired ? '图片已过期' : '点击放大预览'}">
|
||
<span class="msg-attachment-thumb">
|
||
<span class="msg-attachment-thumb-placeholder">${isExpired ? '已过期' : '加载中'}</span>
|
||
<img class="msg-attachment-thumb-image" alt="${name}" hidden>
|
||
</span>
|
||
<span class="msg-attachment-meta">
|
||
<span class="msg-attachment-name">图片: ${name}</span>
|
||
<span class="msg-attachment-note">${isExpired ? '已过期' : `${size} · 点击放大预览`}</span>
|
||
</span>
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
return `<div class="msg-attachments${options.compact ? ' compact' : ''}">${items}</div>`;
|
||
}
|
||
|
||
function renderPendingAttachments() {
|
||
if (!attachmentTray) return;
|
||
if (!pendingAttachments.length && !uploadingAttachments.length) {
|
||
attachmentTray.hidden = true;
|
||
attachmentTray.innerHTML = '';
|
||
syncAttachmentActions();
|
||
return;
|
||
}
|
||
attachmentTray.hidden = false;
|
||
const uploadingHtml = uploadingAttachments.map((attachment) => `
|
||
<div class="attachment-chip uploading">
|
||
<div class="attachment-chip-meta">
|
||
<span class="attachment-chip-name">${escapeHtml(attachment.filename || 'image')}</span>
|
||
<span class="attachment-chip-note">上传中 · ${formatFileSize(attachment.size)}</span>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
const readyHtml = pendingAttachments.map((attachment, index) => `
|
||
<div class="attachment-chip" data-index="${index}">
|
||
<div class="attachment-chip-meta">
|
||
<span class="attachment-chip-name">${escapeHtml(attachment.filename || 'image')}</span>
|
||
<span class="attachment-chip-note">${formatFileSize(attachment.size)} · 将随下一条消息发送</span>
|
||
</div>
|
||
<button class="attachment-chip-remove" type="button" data-index="${index}" title="移除">✕</button>
|
||
</div>
|
||
`).join('');
|
||
const noteHtml = [
|
||
uploadingAttachments.length > 0
|
||
? '<div class="attachment-tray-note">图片上传中,此时发送不会包含尚未完成的图片。</div>'
|
||
: '',
|
||
].join('');
|
||
attachmentTray.innerHTML = `${uploadingHtml}${readyHtml}${noteHtml}`;
|
||
attachmentTray.querySelectorAll('.attachment-chip-remove').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const index = Number(btn.dataset.index);
|
||
const [removed] = pendingAttachments.splice(index, 1);
|
||
renderPendingAttachments();
|
||
deleteUploadedAttachment(removed?.id);
|
||
});
|
||
});
|
||
syncAttachmentActions();
|
||
}
|
||
|
||
async function uploadImageFile(file) {
|
||
await ensureAuthenticatedWs();
|
||
const headers = {
|
||
'Authorization': `Bearer ${authToken}`,
|
||
'Content-Type': file.type || 'application/octet-stream',
|
||
'X-Filename': encodeURIComponent(file.name || 'image'),
|
||
};
|
||
const response = await fetch('/api/attachments', {
|
||
method: 'POST',
|
||
headers,
|
||
body: file,
|
||
});
|
||
const rawText = await response.text();
|
||
let data = null;
|
||
try {
|
||
data = rawText ? JSON.parse(rawText) : null;
|
||
} catch {
|
||
data = null;
|
||
}
|
||
if (response.status === 401) {
|
||
throw new Error('登录状态已失效,请刷新页面后重新登录再上传图片。');
|
||
}
|
||
if (response.status === 413) {
|
||
throw new Error('图片大小超过当前上传限制,请压缩到 10MB 以内后重试。');
|
||
}
|
||
if (!response.ok || !data?.ok) {
|
||
throw new Error(data?.message || `上传失败 (${response.status})`);
|
||
}
|
||
return data.attachment;
|
||
}
|
||
|
||
async function handleSelectedImageFiles(fileList) {
|
||
const files = Array.from(fileList || []).filter((file) => file && /^image\//.test(file.type || ''));
|
||
if (!files.length) return;
|
||
if (pendingAttachments.length + files.length > 4) {
|
||
appendError('单条消息最多附带 4 张图片。');
|
||
return;
|
||
}
|
||
const batch = files.map((file, index) => ({
|
||
id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`,
|
||
filename: file.name || 'image',
|
||
size: file.size || 0,
|
||
}));
|
||
uploadingAttachments.push(...batch);
|
||
renderPendingAttachments();
|
||
try {
|
||
const results = await Promise.allSettled(files.map(async (file) => {
|
||
const optimized = await compressImageFile(file);
|
||
const uploaded = await uploadImageFile(optimized);
|
||
uploaded.previewUrl = URL.createObjectURL(file);
|
||
return uploaded;
|
||
}));
|
||
const errors = [];
|
||
for (const result of results) {
|
||
if (result.status === 'fulfilled') {
|
||
pendingAttachments.push(result.value);
|
||
} else {
|
||
errors.push(result.reason?.message || '图片上传失败');
|
||
}
|
||
}
|
||
if (errors.length > 0) {
|
||
appendError(errors[0]);
|
||
}
|
||
} catch (err) {
|
||
appendError(err.message || '图片上传失败');
|
||
} finally {
|
||
uploadingAttachments = uploadingAttachments.filter((item) => !batch.some((entry) => entry.id === item.id));
|
||
renderPendingAttachments();
|
||
if (imageUploadInput) imageUploadInput.value = '';
|
||
}
|
||
}
|
||
|
||
function getVisibleSessions() {
|
||
return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent);
|
||
}
|
||
|
||
function normalizeSessionSearchQuery(query) {
|
||
return String(query || '').trim().toLowerCase();
|
||
}
|
||
|
||
function syncSessionSearchUi() {
|
||
if (!sessionSearchInput) return;
|
||
if (sessionSearchInput.value !== sessionSearchQuery) {
|
||
sessionSearchInput.value = sessionSearchQuery;
|
||
}
|
||
const hasQuery = !!normalizeSessionSearchQuery(sessionSearchQuery);
|
||
sessionSearchInput.classList.toggle('has-value', hasQuery);
|
||
if (sessionSearchClear) {
|
||
sessionSearchClear.hidden = !hasQuery;
|
||
sessionSearchClear.disabled = !hasQuery;
|
||
}
|
||
}
|
||
|
||
function getSessionSearchText(session) {
|
||
const cwd = getSessionEffectiveCwd(session);
|
||
return [
|
||
session?.title,
|
||
getSessionProjectName(session),
|
||
cwd,
|
||
session?.id,
|
||
shortSessionId(session?.id),
|
||
].filter(Boolean).join('\n').toLowerCase();
|
||
}
|
||
|
||
function sessionMatchesSearch(session, normalizedQuery) {
|
||
if (!normalizedQuery) return true;
|
||
return getSessionSearchText(session).includes(normalizedQuery);
|
||
}
|
||
|
||
function getProjectCollapseKey(group) {
|
||
const rawKey = group?.cwd || group?.name || '';
|
||
return `${normalizeAgent(currentAgent)}:${rawKey}`;
|
||
}
|
||
|
||
function persistCollapsedProjectKeys() {
|
||
try {
|
||
localStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsedProjectKeys]));
|
||
} catch {}
|
||
}
|
||
|
||
function setProjectCollapsed(groupKey, collapsed) {
|
||
if (!groupKey) return;
|
||
if (collapsed) {
|
||
collapsedProjectKeys.add(groupKey);
|
||
} else {
|
||
collapsedProjectKeys.delete(groupKey);
|
||
}
|
||
persistCollapsedProjectKeys();
|
||
renderSessionList();
|
||
}
|
||
|
||
function getSessionCwdFromCache(sessionId) {
|
||
if (!sessionId) return '';
|
||
const cachedCwd = sessionCache.get(sessionId)?.snapshot?.cwd;
|
||
if (cachedCwd) return cachedCwd;
|
||
if (sessionId === currentSessionId && currentCwd) return currentCwd;
|
||
return '';
|
||
}
|
||
|
||
function getSessionEffectiveCwd(session) {
|
||
return session?.cwd || getSessionCwdFromCache(session?.id) || '';
|
||
}
|
||
|
||
function getSessionProjectName(session) {
|
||
if (session?.projectName) return session.projectName;
|
||
const cwd = String(getSessionEffectiveCwd(session)).replace(/\\/g, '/').replace(/\/+$/, '');
|
||
return cwd ? (getPathLeaf(cwd) || cwd) : '';
|
||
}
|
||
|
||
function groupSessionsByProject(sessionItems) {
|
||
const groups = [];
|
||
const groupMap = new Map();
|
||
const ungroupedSessions = [];
|
||
for (const session of sessionItems) {
|
||
const projectName = getSessionProjectName(session);
|
||
if (!projectName) {
|
||
ungroupedSessions.push(session);
|
||
continue;
|
||
}
|
||
if (!groupMap.has(projectName)) {
|
||
const cwd = getSessionEffectiveCwd(session);
|
||
const group = {
|
||
name: projectName,
|
||
cwd,
|
||
sessions: [],
|
||
latestUpdated: session.updated || '',
|
||
};
|
||
groupMap.set(projectName, group);
|
||
groups.push(group);
|
||
}
|
||
const group = groupMap.get(projectName);
|
||
group.sessions.push(session);
|
||
if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) {
|
||
group.latestUpdated = session.updated || group.latestUpdated;
|
||
group.cwd = getSessionEffectiveCwd(session) || group.cwd;
|
||
}
|
||
}
|
||
for (const group of groups) {
|
||
group.sessions.sort(compareSessionUpdatedDesc);
|
||
}
|
||
ungroupedSessions.sort(compareSessionUpdatedDesc);
|
||
return {
|
||
groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)),
|
||
ungroupedSessions,
|
||
};
|
||
}
|
||
|
||
function splitPinnedSessions(sessionItems) {
|
||
const pinnedSessions = [];
|
||
const regularSessions = [];
|
||
for (const session of sessionItems) {
|
||
if (session.pinnedAt) {
|
||
pinnedSessions.push(session);
|
||
} else {
|
||
regularSessions.push(session);
|
||
}
|
||
}
|
||
pinnedSessions.sort(compareSessionPinnedDesc);
|
||
regularSessions.sort(compareSessionUpdatedDesc);
|
||
return { pinnedSessions, regularSessions };
|
||
}
|
||
|
||
function isOlderThanOldSessionWindow(session, nowMs = Date.now()) {
|
||
const updatedMs = new Date(session?.updated || 0).getTime();
|
||
return Number.isFinite(updatedMs) && updatedMs > 0 && nowMs - updatedMs > OLD_SESSION_COLLAPSE_MS;
|
||
}
|
||
|
||
function getProjectOldSessionCollapseKey(group) {
|
||
return `project:${getProjectCollapseKey(group)}`;
|
||
}
|
||
|
||
function getUngroupedOldSessionCollapseKey() {
|
||
return `${normalizeAgent(currentAgent)}:ungrouped`;
|
||
}
|
||
|
||
function expandOldSessionGroup(collapseKey) {
|
||
if (!collapseKey) return;
|
||
expandedOldSessionGroups.add(collapseKey);
|
||
}
|
||
|
||
function shouldAlwaysShowOldSession(session) {
|
||
return session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
|
||
}
|
||
|
||
function splitCollapsedSessions(sessionItems, collapseKey) {
|
||
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
|
||
const nowMs = Date.now();
|
||
const shouldCompactByCount = sessionItems.length > SESSION_GROUP_COMPACT_VISIBLE_LIMIT;
|
||
const visibleSessions = [];
|
||
const hiddenSessions = [];
|
||
|
||
sessionItems.forEach((session) => {
|
||
const isOldSession = isOlderThanOldSessionWindow(session, nowMs);
|
||
const shouldHideByAge = isOldSession && visibleSessions.length >= OLD_SESSION_GROUP_INITIAL_VISIBLE;
|
||
const shouldHideByCount = shouldCompactByCount && visibleSessions.length >= SESSION_GROUP_COMPACT_VISIBLE_LIMIT;
|
||
const shouldHideSession = (
|
||
!isExpanded
|
||
&& !shouldAlwaysShowOldSession(session)
|
||
&& (shouldHideByAge || shouldHideByCount)
|
||
);
|
||
if (shouldHideSession) {
|
||
hiddenSessions.push(session);
|
||
} else {
|
||
visibleSessions.push(session);
|
||
}
|
||
});
|
||
|
||
return { visibleSessions, hiddenSessions };
|
||
}
|
||
|
||
function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'session-list-load-more';
|
||
button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''}会话,还有 ${hiddenCount} 条`);
|
||
button.innerHTML = `
|
||
<span class="session-list-load-more-title">加载更多</span>
|
||
<span class="session-list-load-more-meta">还有 ${hiddenCount} 条</span>
|
||
`;
|
||
button.addEventListener('click', () => {
|
||
expandOldSessionGroup(collapseKey);
|
||
renderSessionList();
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function applySessionPinnedState(sessionId, pinnedAt) {
|
||
sessions = sessions.map((session) => (
|
||
session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session
|
||
));
|
||
updateCachedSession(sessionId, (snapshot) => {
|
||
snapshot.pinnedAt = pinnedAt || null;
|
||
});
|
||
renderSessionList();
|
||
}
|
||
|
||
function toggleSessionPinned(session) {
|
||
if (!session?.id) return;
|
||
const nextPinned = !session.pinnedAt;
|
||
const pinnedAt = nextPinned ? new Date().toISOString() : null;
|
||
applySessionPinnedState(session.id, pinnedAt);
|
||
send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned });
|
||
}
|
||
|
||
function setSessionActionMenuOpen(item, open) {
|
||
if (!item) return;
|
||
item.classList.toggle('menu-open', open);
|
||
item.querySelector('.session-item-btn.more')?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}
|
||
|
||
function closeSessionActionMenus(exceptItem = null) {
|
||
document.querySelectorAll('.session-item.menu-open').forEach((item) => {
|
||
if (item !== exceptItem) setSessionActionMenuOpen(item, false);
|
||
});
|
||
}
|
||
|
||
function createSessionListItem(session) {
|
||
const item = document.createElement('div');
|
||
const isPinned = !!session.pinnedAt;
|
||
const waitingOnChildren = !!session.waitingOnChildren;
|
||
const readyReplyCount = Number(session.readyReplyCount || 0);
|
||
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
|
||
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
|
||
item.dataset.id = session.id;
|
||
const sessionCwd = getSessionEffectiveCwd(session);
|
||
if (sessionCwd) item.title = sessionCwd;
|
||
item.innerHTML = `
|
||
<div class="session-item-main">
|
||
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
|
||
${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
|
||
${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
|
||
${!session.isRunning && waitingOnChildren ? `<span class="session-item-status waiting">${escapeHtml(waitingLabel)}</span>` : ''}
|
||
</div>
|
||
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
|
||
<span class="session-item-time">${timeAgo(session.updated)}</span>
|
||
<div class="session-item-actions">
|
||
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
|
||
<div class="session-item-more">
|
||
<button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false">⋯</button>
|
||
<div class="session-item-menu" role="menu" aria-label="会话操作">
|
||
<button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
|
||
<button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
|
||
<button class="session-item-menu-btn delete" type="button" role="menuitem">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
item.addEventListener('click', (e) => {
|
||
const target = e.target instanceof Element
|
||
? e.target.closest('.session-item-btn, .session-item-menu-btn')
|
||
: null;
|
||
if (target?.classList.contains('more')) {
|
||
e.stopPropagation();
|
||
const nextOpen = !item.classList.contains('menu-open');
|
||
closeSessionActionMenus(item);
|
||
setSessionActionMenuOpen(item, nextOpen);
|
||
return;
|
||
}
|
||
if (target?.classList.contains('copy-id')) {
|
||
e.stopPropagation();
|
||
closeSessionActionMenus();
|
||
copyTextToClipboard(session.id, '会话 ID 已复制');
|
||
return;
|
||
}
|
||
if (target?.classList.contains('pin')) {
|
||
e.stopPropagation();
|
||
closeSessionActionMenus();
|
||
toggleSessionPinned(session);
|
||
return;
|
||
}
|
||
if (target?.classList.contains('delete')) {
|
||
e.stopPropagation();
|
||
closeSessionActionMenus();
|
||
const doDelete = () => {
|
||
if (getLastSessionForAgent(currentAgent) === session.id) {
|
||
localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
|
||
}
|
||
pendingNotesByTarget.delete(session.id);
|
||
queuedMessagesByTarget.delete(getSessionQueueKey(session.id));
|
||
invalidateSessionCache(session.id);
|
||
send({ type: 'delete_session', sessionId: session.id });
|
||
if (session.id === currentSessionId) {
|
||
resetChatView(currentAgent);
|
||
}
|
||
};
|
||
if (skipDeleteConfirm) {
|
||
doDelete();
|
||
} else {
|
||
showDeleteConfirm(session.agent, doDelete);
|
||
}
|
||
return;
|
||
}
|
||
if (target?.classList.contains('edit')) {
|
||
e.stopPropagation();
|
||
closeSessionActionMenus();
|
||
startEditSessionTitle(item, session);
|
||
return;
|
||
}
|
||
if (e.target instanceof Element && e.target.closest('.session-item-more')) {
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
closeSessionActionMenus();
|
||
if (isMobileInputMode()) closeSidebar();
|
||
openSession(session.id);
|
||
});
|
||
|
||
return item;
|
||
}
|
||
|
||
function updateCwdBadge() {
|
||
if (!chatCwd) return;
|
||
if (currentCwd) {
|
||
const parts = currentCwd.replace(/\/+$/, '').split('/');
|
||
const short = parts.slice(-2).join('/') || currentCwd;
|
||
chatCwd.textContent = '~/' + short;
|
||
chatCwd.title = `${currentCwd}\n点击浏览目录和文件`;
|
||
chatCwd.setAttribute('aria-label', `浏览工作目录 ${currentCwd}`);
|
||
} else {
|
||
chatCwd.textContent = '';
|
||
chatCwd.title = '';
|
||
chatCwd.removeAttribute('aria-label');
|
||
}
|
||
chatCwd.disabled = !currentCwd;
|
||
chatCwd.hidden = !currentCwd;
|
||
}
|
||
|
||
function currentSessionWaitState() {
|
||
const meta = currentSessionId ? getSessionMeta(currentSessionId) : null;
|
||
const cached = currentSessionId ? sessionCache.get(currentSessionId)?.snapshot : null;
|
||
const source = meta || cached || {};
|
||
return {
|
||
waitingOnChildren: !!source.waitingOnChildren,
|
||
pendingReplyCount: Number(source.pendingReplyCount || 0),
|
||
readyReplyCount: Number(source.readyReplyCount || 0),
|
||
};
|
||
}
|
||
|
||
function setCurrentSessionRunningState(isRunning) {
|
||
const running = !!isRunning;
|
||
currentSessionRunning = running;
|
||
if (chatRuntimeState) {
|
||
const waitState = currentSessionWaitState();
|
||
if (running) {
|
||
chatRuntimeState.hidden = false;
|
||
chatRuntimeState.classList.remove('waiting');
|
||
chatRuntimeState.textContent = '运行中';
|
||
} else if (waitState.waitingOnChildren) {
|
||
chatRuntimeState.hidden = false;
|
||
chatRuntimeState.classList.add('waiting');
|
||
chatRuntimeState.textContent = waitState.readyReplyCount > 0
|
||
? `子对话已返回 ${waitState.readyReplyCount}`
|
||
: `等待子对话 ${waitState.pendingReplyCount || ''}`.trim();
|
||
} else {
|
||
chatRuntimeState.hidden = true;
|
||
chatRuntimeState.classList.remove('waiting');
|
||
chatRuntimeState.textContent = '';
|
||
}
|
||
}
|
||
updateCwdBadge();
|
||
if (!running) scheduleQueuedMessageDrain();
|
||
}
|
||
|
||
function updateAgentScopedUI() {
|
||
if (chatAgentBtn) {
|
||
chatAgentBtn.textContent = AGENT_LABELS[currentAgent];
|
||
chatAgentBtn.setAttribute('aria-expanded', chatAgentMenu && !chatAgentMenu.hidden ? 'true' : 'false');
|
||
}
|
||
if (chatAgentMenu) {
|
||
chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => {
|
||
const active = btn.dataset.agent === currentAgent;
|
||
btn.classList.toggle('active', active);
|
||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
});
|
||
}
|
||
if (importSessionBtn) {
|
||
importSessionBtn.textContent = isCodexLikeAgent(currentAgent)
|
||
? `导入本地 ${AGENT_LABELS[currentAgent]} 会话`
|
||
: '导入本地 Claude 会话';
|
||
importSessionBtn.disabled = false;
|
||
}
|
||
updateReloadMcpButtonUI();
|
||
}
|
||
|
||
function setCurrentAgent(agent) {
|
||
currentAgent = normalizeAgent(agent);
|
||
localStorage.setItem('cc-web-agent', currentAgent);
|
||
currentMode = localStorage.getItem(getAgentModeStorageKey(currentAgent)) || 'yolo';
|
||
modeSelect.value = currentMode;
|
||
updateAgentScopedUI();
|
||
updateGenerationControls();
|
||
}
|
||
|
||
function closeAgentMenu() {
|
||
if (!chatAgentMenu) return;
|
||
chatAgentMenu.hidden = true;
|
||
if (chatAgentBtn) chatAgentBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function toggleAgentMenu() {
|
||
if (!chatAgentMenu || !chatAgentBtn) return;
|
||
const willOpen = chatAgentMenu.hidden;
|
||
chatAgentMenu.hidden = !willOpen;
|
||
chatAgentBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||
}
|
||
|
||
function resetChatView(agent) {
|
||
setCurrentAgent(agent);
|
||
closeUserOutlinePanel();
|
||
closeCcwebPromptOutlinePanel();
|
||
closeFileBrowser();
|
||
currentSessionId = null;
|
||
loadedHistorySessionId = null;
|
||
currentSessionMessageCount = 0;
|
||
clearSessionLoading();
|
||
setCurrentSessionRunningState(false);
|
||
currentCwd = null;
|
||
currentModel = isCodexLikeAgent(currentAgent) ? '' : 'opus';
|
||
isGenerating = false;
|
||
generatingSessionId = null;
|
||
pendingText = '';
|
||
window.pendingContentBlocks = [];
|
||
pendingAttachments = [];
|
||
uploadingAttachments = [];
|
||
activeToolCalls.clear();
|
||
activeTodoCallTargets.clear();
|
||
closedCollabAgentIds = new Set();
|
||
updateGenerationControls();
|
||
chatTitle.textContent = '新会话';
|
||
updateSessionIdBadge();
|
||
updateCwdBadge();
|
||
updateReloadMcpButtonUI();
|
||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||
setStatsDisplay(null);
|
||
renderPendingAttachments();
|
||
renderPendingNotes({ scroll: false });
|
||
highlightActiveSession();
|
||
}
|
||
|
||
function applySessionSnapshot(snapshot, options = {}) {
|
||
if (!snapshot) return;
|
||
const snapshotAgent = normalizeAgent(snapshot.agent);
|
||
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
|
||
closeFileBrowser();
|
||
}
|
||
const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning);
|
||
if (isGenerating && !preserveStreaming) {
|
||
isGenerating = false;
|
||
generatingSessionId = null;
|
||
updateGenerationControls();
|
||
pendingText = '';
|
||
window.pendingContentBlocks = [];
|
||
activeToolCalls.clear();
|
||
activeTodoCallTargets.clear();
|
||
}
|
||
currentSessionId = snapshot.sessionId;
|
||
loadedHistorySessionId = snapshot.sessionId;
|
||
currentSessionMessageCount = Math.max(
|
||
snapshot.historyTotal || 0,
|
||
snapshot.historyBaseIndex + (snapshot.messages || []).length,
|
||
(snapshot.messages || []).length,
|
||
);
|
||
setLastSessionForAgent(snapshot.agent, currentSessionId);
|
||
chatTitle.textContent = snapshot.title || '新会话';
|
||
updateSessionIdBadge();
|
||
setCurrentAgent(snapshotAgent);
|
||
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
|
||
migrateQueuedMessagesToSession(snapshot.sessionId, snapshotAgent);
|
||
setCurrentSessionRunningState(snapshot.isRunning);
|
||
setStatsDisplay(snapshot);
|
||
closeUserOutlinePanel();
|
||
closeCcwebPromptOutlinePanel();
|
||
currentCwd = snapshot.cwd || null;
|
||
updateCwdBadge();
|
||
if (snapshot.mode && MODE_LABELS[snapshot.mode]) {
|
||
currentMode = snapshot.mode;
|
||
modeSelect.value = currentMode;
|
||
localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
|
||
}
|
||
currentModel = snapshot.model || '';
|
||
if (!preserveStreaming) {
|
||
renderMessages(snapshot.messages || [], {
|
||
immediate: !!options.immediate,
|
||
baseIndex: snapshot.historyBaseIndex || 0,
|
||
});
|
||
if (snapshot.isRunning && snapshot.sessionId === currentSessionId) {
|
||
startGenerating(snapshot.sessionId);
|
||
}
|
||
} else {
|
||
generatingSessionId = snapshot.sessionId;
|
||
}
|
||
highlightActiveSession();
|
||
renderSessionList();
|
||
if (!options.skipCloseSidebar) closeSidebar();
|
||
if (snapshot.hasUnread && !options.suppressUnreadToast) {
|
||
showToast('后台任务已完成', snapshot.sessionId);
|
||
}
|
||
}
|
||
|
||
function syncViewForAgent(agent, options = {}) {
|
||
const targetAgent = normalizeAgent(agent);
|
||
const { preserveCurrent = true, loadLast = true } = options;
|
||
setCurrentAgent(targetAgent);
|
||
closeUserOutlinePanel();
|
||
closeCcwebPromptOutlinePanel();
|
||
renderSessionList();
|
||
|
||
const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null;
|
||
if (preserveCurrent && currentMeta && normalizeAgent(currentMeta.agent) === targetAgent) {
|
||
highlightActiveSession();
|
||
return;
|
||
}
|
||
|
||
if (currentSessionId && (!currentMeta || normalizeAgent(currentMeta.agent) !== targetAgent)) {
|
||
send({ type: 'detach_view' });
|
||
}
|
||
|
||
resetChatView(targetAgent);
|
||
|
||
if (!loadLast) return;
|
||
const lastSessionId = getLastSessionForAgent(targetAgent);
|
||
const lastMeta = lastSessionId ? getSessionMeta(lastSessionId) : null;
|
||
if (lastMeta && normalizeAgent(lastMeta.agent) === targetAgent) {
|
||
openSession(lastSessionId);
|
||
}
|
||
}
|
||
|
||
function getSessionLoadLabel(sessionId) {
|
||
const meta = sessionId ? getSessionMeta(sessionId) : null;
|
||
const title = meta?.title ? `“${meta.title}”` : '所选会话';
|
||
return `正在载入 ${title} 的完整消息记录…`;
|
||
}
|
||
|
||
function clearSessionLoadOverlayTimer() {
|
||
if (!sessionLoadOverlayTimer) return;
|
||
clearTimeout(sessionLoadOverlayTimer);
|
||
sessionLoadOverlayTimer = null;
|
||
}
|
||
|
||
function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) {
|
||
clearSessionLoadOverlayTimer();
|
||
document.body.classList.remove('session-loading-active');
|
||
sessionLoadingOverlay.hidden = true;
|
||
sessionLoadingOverlay.setAttribute('aria-hidden', 'true');
|
||
msgInput.disabled = false;
|
||
modeSelect.disabled = false;
|
||
sendBtn.disabled = false;
|
||
abortBtn.disabled = false;
|
||
if (keepActiveLoad && activeSessionLoad) {
|
||
if (allowRetry) activeSessionLoad.overlayReleased = true;
|
||
}
|
||
}
|
||
|
||
function setSessionLoading(sessionId, options = {}) {
|
||
clearSessionLoadOverlayTimer();
|
||
const loading = !!sessionId;
|
||
const blocking = options.blocking !== false;
|
||
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
|
||
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId, overlayReleased: false } : null;
|
||
const showOverlay = !!(loading && blocking);
|
||
if (showOverlay) {
|
||
document.body.classList.add('session-loading-active');
|
||
sessionLoadingOverlay.hidden = false;
|
||
sessionLoadingOverlay.setAttribute('aria-hidden', 'false');
|
||
sessionLoadOverlayTimer = setTimeout(() => {
|
||
sessionLoadOverlayTimer = null;
|
||
if (!activeSessionLoad || activeSessionLoad.sessionId !== sessionId || activeSessionLoad.requestId !== requestId) return;
|
||
releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true });
|
||
}, SESSION_LOAD_OVERLAY_TIMEOUT_MS);
|
||
} else {
|
||
releaseSessionLoadingOverlay({ keepActiveLoad: loading });
|
||
}
|
||
sessionLoadingLabel.textContent = loading ? (options.label || getSessionLoadLabel(sessionId)) : '正在整理消息与上下文…';
|
||
msgInput.disabled = showOverlay;
|
||
modeSelect.disabled = showOverlay;
|
||
sendBtn.disabled = showOverlay;
|
||
abortBtn.disabled = showOverlay;
|
||
if (showOverlay && document.activeElement instanceof HTMLElement) {
|
||
document.activeElement.blur();
|
||
}
|
||
}
|
||
|
||
function clearSessionLoading(sessionId) {
|
||
if (sessionId && activeSessionLoad && activeSessionLoad.sessionId !== sessionId) return;
|
||
setSessionLoading(null, { blocking: false });
|
||
}
|
||
|
||
function createSessionSwitchRequestId(sessionId) {
|
||
sessionSwitchRequestSeq += 1;
|
||
return `session-load-${Date.now()}-${sessionSwitchRequestSeq}-${String(sessionId || '').slice(0, 8)}`;
|
||
}
|
||
|
||
function isBlockingSessionLoad(sessionId) {
|
||
return !!(activeSessionLoad &&
|
||
activeSessionLoad.blocking &&
|
||
(!sessionId || activeSessionLoad.sessionId === sessionId));
|
||
}
|
||
|
||
function finishSessionSwitch(sessionId) {
|
||
if (isBlockingSessionLoad(sessionId)) {
|
||
scrollToBottom();
|
||
requestAnimationFrame(() => clearSessionLoading(sessionId));
|
||
return;
|
||
}
|
||
clearSessionLoading(sessionId);
|
||
}
|
||
|
||
function finalizeLoadedSession(sessionId) {
|
||
if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) {
|
||
activeSessionLoad.snapshot.complete = true;
|
||
cacheSessionSnapshot(activeSessionLoad.snapshot);
|
||
}
|
||
finishSessionSwitch(sessionId);
|
||
}
|
||
|
||
function beginSessionSwitch(sessionId, options = {}) {
|
||
if (!sessionId) return;
|
||
const blocking = options.blocking !== false;
|
||
const force = options.force === true;
|
||
if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return;
|
||
if (!force && sessionId === currentSessionId && !activeSessionLoad) return;
|
||
renderEpoch++;
|
||
loadedHistorySessionId = null;
|
||
setSessionLoading(sessionId, { blocking, label: options.label });
|
||
requestSessionLoad(sessionId, { blocking, label: options.label });
|
||
}
|
||
|
||
function requestSessionLoad(sessionId, options = {}) {
|
||
if (!sessionId) return;
|
||
pendingSessionSwitchRequest = {
|
||
sessionId,
|
||
blocking: options.blocking !== false,
|
||
label: options.label || '',
|
||
requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
|
||
};
|
||
if (ws && ws.readyState === 1 && wsAuthenticated) {
|
||
flushPendingSessionSwitch();
|
||
return;
|
||
}
|
||
if (!ws || ws.readyState > 1) connect();
|
||
}
|
||
|
||
function flushPendingSessionSwitch() {
|
||
if (!pendingSessionSwitchRequest) return;
|
||
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return;
|
||
const request = pendingSessionSwitchRequest;
|
||
pendingSessionSwitchRequest = null;
|
||
if (!activeSessionLoad) {
|
||
setSessionLoading(request.sessionId, {
|
||
blocking: request.blocking,
|
||
label: request.label || undefined,
|
||
requestId: request.requestId,
|
||
});
|
||
}
|
||
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId }));
|
||
}
|
||
|
||
function showCachedSession(sessionId) {
|
||
const snapshot = buildCachedSessionSnapshot(sessionId);
|
||
if (!snapshot) return false;
|
||
if (currentSessionId && currentSessionId !== sessionId) {
|
||
send({ type: 'detach_view' });
|
||
}
|
||
closeUserOutlinePanel();
|
||
closeCcwebPromptOutlinePanel();
|
||
clearSessionLoading();
|
||
touchSessionCache(sessionId);
|
||
applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
|
||
return true;
|
||
}
|
||
|
||
function openSession(sessionId, options = {}) {
|
||
if (!sessionId) return;
|
||
closeUserOutlinePanel();
|
||
closeCcwebPromptOutlinePanel();
|
||
if (options.forceSync) {
|
||
beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label });
|
||
return;
|
||
}
|
||
if (!options.force && sessionId === currentSessionId && !activeSessionLoad) return;
|
||
|
||
const disposition = getSessionCacheDisposition(sessionId);
|
||
if (disposition === 'strong') {
|
||
showCachedSession(sessionId);
|
||
return;
|
||
}
|
||
if (disposition === 'weak' && showCachedSession(sessionId)) {
|
||
beginSessionSwitch(sessionId, { blocking: false, force: true, label: options.label });
|
||
return;
|
||
}
|
||
beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: options.force === true, label: options.label });
|
||
}
|
||
|
||
function setStatsDisplay(msg) {
|
||
if (isCodexLikeAgent(currentAgent) && msg && msg.totalUsage) {
|
||
const usage = msg.totalUsage;
|
||
if ((usage.inputTokens || 0) > 0 || (usage.outputTokens || 0) > 0) {
|
||
const cacheText = usage.cachedInputTokens ? ` · cache ${usage.cachedInputTokens}` : '';
|
||
costDisplay.textContent = `in ${usage.inputTokens} · out ${usage.outputTokens}${cacheText}`;
|
||
return;
|
||
}
|
||
}
|
||
if (msg && typeof msg.totalCost === 'number' && msg.totalCost > 0) {
|
||
costDisplay.textContent = `$${msg.totalCost.toFixed(4)}`;
|
||
return;
|
||
}
|
||
costDisplay.textContent = '';
|
||
}
|
||
|
||
function _splitCodexThinkingModel(model) {
|
||
const raw = String(model || '').trim();
|
||
if (!raw) return { base: '', level: '' };
|
||
const m = raw.match(/^(.*)\(([^()]+)\)\s*$/);
|
||
if (!m) return { base: raw, level: '' };
|
||
return { base: (m[1] || '').trim(), level: (m[2] || '').trim().toLowerCase() };
|
||
}
|
||
|
||
function _isCodexModelAtLeast52(model) {
|
||
const { base } = _splitCodexThinkingModel(model);
|
||
// Accept only GPT-5.2+ (hide/remove older and other families from picker).
|
||
const m = String(base || '').trim().match(/^gpt-5\.(\d+)(?:-.+)?$/i);
|
||
if (!m) return false;
|
||
const minor = Number(m[1] || 0);
|
||
return Number.isFinite(minor) && minor >= 2;
|
||
}
|
||
|
||
function getCodexBaseModelOptions() {
|
||
const seen = new Set();
|
||
const options = [];
|
||
|
||
function addOption(value, label, desc) {
|
||
const v = (value || '').trim();
|
||
if (!v || seen.has(v)) return;
|
||
seen.add(v);
|
||
options.push({ value: v, label: label || v, desc: desc || 'Codex 模型' });
|
||
}
|
||
|
||
function addBaseOption(value, label, desc) {
|
||
if (!_isCodexModelAtLeast52(value)) return;
|
||
const { base } = _splitCodexThinkingModel(value);
|
||
addOption(base, label || base, desc);
|
||
}
|
||
|
||
DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc));
|
||
addBaseOption(currentModel, currentModel, '当前会话模型');
|
||
sessions
|
||
.filter((s) => isCodexLikeAgent(s.agent) && s.id === currentSessionId)
|
||
.forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型'));
|
||
|
||
return options;
|
||
}
|
||
|
||
// --- marked config ---
|
||
const PREVIEW_LANGS = new Set(['html', 'svg']);
|
||
const MERMAID_LANGS = new Set(['mermaid', 'mmd']);
|
||
const _previewCodeMap = new Map();
|
||
let _previewCodeId = 0;
|
||
let _mermaidInitialized = false;
|
||
let _mermaidRenderId = 0;
|
||
|
||
function normalizeCodeLanguage(language) {
|
||
const lang = String(language || '').trim().split(/\s+/)[0].toLowerCase();
|
||
return lang || 'plaintext';
|
||
}
|
||
|
||
function highlightCodeBlock(code, lang) {
|
||
try {
|
||
if (hljs.getLanguage(lang)) {
|
||
return hljs.highlight(code, { language: lang }).value;
|
||
}
|
||
return hljs.highlightAuto(code).value;
|
||
} catch {
|
||
return escapeHtml(code);
|
||
}
|
||
}
|
||
|
||
function getCodeBlockSource(wrapper) {
|
||
if (!wrapper) return '';
|
||
const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
|
||
if (cid && _previewCodeMap.has(cid)) return _previewCodeMap.get(cid);
|
||
return wrapper.querySelector('code')?.textContent || '';
|
||
}
|
||
|
||
function createStoredCodeId(code) {
|
||
const cid = ++_previewCodeId;
|
||
_previewCodeMap.set(cid, code);
|
||
return cid;
|
||
}
|
||
|
||
function renderMermaidCodeBlock(code, lang) {
|
||
const cid = createStoredCodeId(code);
|
||
const highlighted = highlightCodeBlock(code, lang);
|
||
return `<div class="code-block-wrapper mermaid-code-block mermaid-diagram-mode" data-cid="${cid}">
|
||
<div class="code-block-header">
|
||
<span>${escapeHtml(lang)}</span>
|
||
<div class="code-block-actions">
|
||
<button class="code-preview-btn mermaid-toggle-btn" type="button" onclick="ccToggleMermaid(this, event)" aria-label="切换 Mermaid 图形与代码">代码</button>
|
||
<button class="code-preview-btn mermaid-copy-graph-btn" type="button" onclick="ccCopyMermaidDiagram(this, event)" aria-label="复制 Mermaid 图形">复制图</button>
|
||
<button class="code-preview-btn mermaid-download-btn" type="button" onclick="ccDownloadMermaidDiagram(this, event)" aria-label="下载 Mermaid 图形">下载图</button>
|
||
<button class="code-copy-btn" type="button" onclick="ccCopyCode(this, event)" aria-label="复制 Mermaid 代码">Copy</button>
|
||
</div>
|
||
</div>
|
||
<div class="mermaid-render-pane" data-render-state="pending">
|
||
<div class="mermaid-render-target" aria-label="Mermaid 图形"></div>
|
||
<div class="mermaid-render-error" role="alert" hidden></div>
|
||
</div>
|
||
<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
|
||
</div>`;
|
||
}
|
||
|
||
const renderer = new marked.Renderer();
|
||
renderer.code = function (code, language) {
|
||
const source = String(code || '');
|
||
const lang = normalizeCodeLanguage(language);
|
||
if (MERMAID_LANGS.has(lang)) return renderMermaidCodeBlock(source, lang);
|
||
|
||
const highlighted = highlightCodeBlock(source, lang);
|
||
const canPreview = PREVIEW_LANGS.has(lang);
|
||
const previewBtn = canPreview
|
||
? `<button class="code-preview-btn" type="button" onclick="ccTogglePreview(this, event)" aria-label="预览代码块">Preview</button>`
|
||
: '';
|
||
const previewPane = canPreview
|
||
? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
|
||
: '';
|
||
const cid = canPreview ? createStoredCodeId(source) : 0;
|
||
return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
|
||
<div class="code-block-header">
|
||
<span>${escapeHtml(lang)}</span>
|
||
<div class="code-block-actions">${previewBtn}<button class="code-copy-btn" type="button" onclick="ccCopyCode(this, event)" aria-label="复制代码块">Copy</button></div>
|
||
</div>
|
||
${previewPane}<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
|
||
</div>`;
|
||
};
|
||
marked.setOptions({ renderer, breaks: true, gfm: true });
|
||
|
||
window.ccCopyCode = async function (btn, event) {
|
||
event?.preventDefault();
|
||
event?.stopPropagation();
|
||
const wrapper = btn?.closest?.('.code-block-wrapper');
|
||
if (!wrapper) return;
|
||
const code = getCodeBlockSource(wrapper);
|
||
const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy';
|
||
btn.dataset.defaultLabel = defaultLabel;
|
||
btn.disabled = true;
|
||
const copied = await copyTextToClipboard(code, '代码已复制');
|
||
btn.disabled = false;
|
||
if (!copied) return;
|
||
if (btn._copyResetTimer) clearTimeout(btn._copyResetTimer);
|
||
btn.textContent = 'Copied!';
|
||
btn._copyResetTimer = setTimeout(() => {
|
||
btn.textContent = btn.dataset.defaultLabel || 'Copy';
|
||
btn._copyResetTimer = null;
|
||
}, 1500);
|
||
};
|
||
|
||
window.ccTogglePreview = function (btn, event) {
|
||
event?.preventDefault();
|
||
event?.stopPropagation();
|
||
const wrapper = btn.closest('.code-block-wrapper');
|
||
const inPreview = wrapper.classList.contains('preview-mode');
|
||
if (inPreview) {
|
||
wrapper.classList.remove('preview-mode');
|
||
btn.textContent = 'Preview';
|
||
} else {
|
||
const iframe = wrapper.querySelector('.code-preview-iframe');
|
||
if (iframe && !iframe.dataset.loaded) {
|
||
const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
|
||
iframe.srcdoc = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : '';
|
||
iframe.dataset.loaded = '1';
|
||
}
|
||
wrapper.classList.add('preview-mode');
|
||
btn.textContent = 'Source';
|
||
}
|
||
};
|
||
|
||
function ensureMermaidInitialized() {
|
||
if (!window.mermaid?.render) throw new Error('Mermaid 加载失败');
|
||
if (!_mermaidInitialized) {
|
||
window.mermaid.initialize({
|
||
startOnLoad: false,
|
||
securityLevel: 'strict',
|
||
theme: 'default',
|
||
});
|
||
_mermaidInitialized = true;
|
||
}
|
||
return window.mermaid;
|
||
}
|
||
|
||
function setMermaidRenderError(wrapper, message) {
|
||
const pane = wrapper?.querySelector?.('.mermaid-render-pane');
|
||
const errorEl = wrapper?.querySelector?.('.mermaid-render-error');
|
||
if (!pane || !errorEl) return;
|
||
pane.dataset.renderState = 'error';
|
||
errorEl.hidden = false;
|
||
errorEl.textContent = message || 'Mermaid 图形渲染失败';
|
||
}
|
||
|
||
async function hydrateMermaidBlock(wrapper, force = false) {
|
||
if (!wrapper || (!force && wrapper.dataset.mermaidRendered === '1')) return true;
|
||
const pane = wrapper.querySelector('.mermaid-render-pane');
|
||
const target = wrapper.querySelector('.mermaid-render-target');
|
||
const errorEl = wrapper.querySelector('.mermaid-render-error');
|
||
if (!pane || !target) return false;
|
||
|
||
const code = getCodeBlockSource(wrapper).trim();
|
||
if (!code) {
|
||
setMermaidRenderError(wrapper, 'Mermaid 代码为空');
|
||
return false;
|
||
}
|
||
|
||
const renderKey = String(++_mermaidRenderId);
|
||
wrapper.dataset.mermaidRenderKey = renderKey;
|
||
wrapper.dataset.mermaidRendered = '0';
|
||
pane.dataset.renderState = 'pending';
|
||
target.innerHTML = '';
|
||
if (errorEl) {
|
||
errorEl.hidden = true;
|
||
errorEl.textContent = '';
|
||
}
|
||
|
||
try {
|
||
const mermaidApi = ensureMermaidInitialized();
|
||
const result = await mermaidApi.render(`ccweb-mermaid-${renderKey}`, code);
|
||
if (!wrapper.isConnected || wrapper.dataset.mermaidRenderKey !== renderKey) return false;
|
||
target.innerHTML = typeof result === 'string' ? result : result.svg;
|
||
if (typeof result?.bindFunctions === 'function') result.bindFunctions(target);
|
||
pane.dataset.renderState = 'rendered';
|
||
wrapper.dataset.mermaidRendered = '1';
|
||
return true;
|
||
} catch (error) {
|
||
if (!wrapper.isConnected || wrapper.dataset.mermaidRenderKey !== renderKey) return false;
|
||
setMermaidRenderError(wrapper, error?.message || 'Mermaid 图形渲染失败');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function hydrateMermaidBlocks(root) {
|
||
if (!root) return;
|
||
const blocks = root.matches?.('.mermaid-code-block')
|
||
? [root]
|
||
: Array.from(root.querySelectorAll('.mermaid-code-block'));
|
||
blocks.forEach((block) => {
|
||
hydrateMermaidBlock(block);
|
||
});
|
||
}
|
||
|
||
function handleLocalFileLinkClick(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const link = event.currentTarget;
|
||
const filePath = link?.dataset?.filePath || '';
|
||
const line = Number(link?.dataset?.fileLine || 0) || 0;
|
||
const relativePath = getWorkspaceRelativePath(filePath);
|
||
if (!relativePath) {
|
||
showToast(currentCwd ? '文件不在当前会话工作目录内' : '当前会话没有可浏览的工作目录');
|
||
return;
|
||
}
|
||
|
||
showFileBrowser();
|
||
if (!fileBrowserState) return;
|
||
|
||
loadFileBrowserDirectory(getBrowserParentPath(relativePath), { preservePreview: true });
|
||
openFileBrowserFile(relativePath, { line });
|
||
}
|
||
|
||
function hydrateLocalFileLinks(root) {
|
||
if (!root) return;
|
||
const links = root.matches?.('a')
|
||
? [root]
|
||
: Array.from(root.querySelectorAll('a'));
|
||
|
||
links.forEach((link) => {
|
||
if (link.dataset.localFileHydrated === '1') return;
|
||
const parsed = parseLocalFileLinkHref(link.getAttribute('href') || '');
|
||
if (!parsed) return;
|
||
|
||
link.dataset.localFileHydrated = '1';
|
||
link.dataset.localFileLink = 'true';
|
||
link.dataset.filePath = parsed.filePath;
|
||
link.dataset.fileLine = parsed.line ? String(parsed.line) : '';
|
||
link.classList.add('local-file-link');
|
||
link.title = parsed.line ? `${parsed.filePath}:${parsed.line}` : parsed.filePath;
|
||
link.textContent = formatLocalFileLinkText(parsed, link.textContent);
|
||
link.addEventListener('click', handleLocalFileLinkClick);
|
||
});
|
||
}
|
||
|
||
function hydrateRenderedMarkdown(root) {
|
||
hydrateLocalFileLinks(root);
|
||
hydrateMermaidBlocks(root);
|
||
}
|
||
|
||
function setRenderedMarkdown(target, text, hydrate = true) {
|
||
target.innerHTML = renderMarkdown(text);
|
||
if (hydrate) hydrateRenderedMarkdown(target);
|
||
}
|
||
|
||
window.ccToggleMermaid = function (btn, event) {
|
||
event?.preventDefault();
|
||
event?.stopPropagation();
|
||
const wrapper = btn?.closest?.('.mermaid-code-block');
|
||
if (!wrapper) return;
|
||
const showCode = wrapper.classList.contains('mermaid-diagram-mode');
|
||
wrapper.classList.toggle('mermaid-diagram-mode', !showCode);
|
||
wrapper.classList.toggle('mermaid-source-mode', showCode);
|
||
btn.textContent = showCode ? '图' : '代码';
|
||
if (!showCode) hydrateMermaidBlock(wrapper);
|
||
};
|
||
|
||
function serializeMermaidSvg(wrapper) {
|
||
const svg = wrapper?.querySelector?.('.mermaid-render-target svg');
|
||
if (!svg) return '';
|
||
const clone = svg.cloneNode(true);
|
||
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||
if (!clone.getAttribute('width') || !clone.getAttribute('height')) {
|
||
const viewBox = clone.getAttribute('viewBox');
|
||
const parts = viewBox ? viewBox.trim().split(/\s+/).map(Number) : [];
|
||
if (parts.length === 4 && parts.every(Number.isFinite)) {
|
||
clone.setAttribute('width', String(Math.max(1, Math.ceil(parts[2]))));
|
||
clone.setAttribute('height', String(Math.max(1, Math.ceil(parts[3]))));
|
||
}
|
||
}
|
||
return new XMLSerializer().serializeToString(clone);
|
||
}
|
||
|
||
async function getMermaidSvgText(wrapper) {
|
||
if (!serializeMermaidSvg(wrapper)) {
|
||
await hydrateMermaidBlock(wrapper, true);
|
||
}
|
||
return serializeMermaidSvg(wrapper);
|
||
}
|
||
|
||
function svgTextToPngBlob(svgText) {
|
||
return new Promise((resolve, reject) => {
|
||
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
|
||
const url = URL.createObjectURL(svgBlob);
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
const width = Math.max(1, image.naturalWidth || image.width || 1200);
|
||
const height = Math.max(1, image.naturalHeight || image.height || 800);
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, width, height);
|
||
ctx.drawImage(image, 0, 0, width, height);
|
||
canvas.toBlob((blob) => {
|
||
URL.revokeObjectURL(url);
|
||
if (blob) resolve(blob);
|
||
else reject(new Error('PNG 导出失败'));
|
||
}, 'image/png');
|
||
} catch (error) {
|
||
URL.revokeObjectURL(url);
|
||
reject(error);
|
||
}
|
||
};
|
||
image.onerror = () => {
|
||
URL.revokeObjectURL(url);
|
||
reject(new Error('SVG 图形读取失败'));
|
||
};
|
||
image.src = url;
|
||
});
|
||
}
|
||
|
||
async function copyBlobToClipboard(blob, type) {
|
||
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined' || !window.isSecureContext) return false;
|
||
await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]);
|
||
return true;
|
||
}
|
||
|
||
function setTemporaryButtonLabel(btn, label, fallback, timeout = 1500) {
|
||
if (!btn) return;
|
||
const defaultLabel = btn.dataset.defaultLabel || fallback || btn.textContent || '';
|
||
btn.dataset.defaultLabel = defaultLabel;
|
||
if (btn._labelResetTimer) clearTimeout(btn._labelResetTimer);
|
||
btn.textContent = label;
|
||
btn._labelResetTimer = setTimeout(() => {
|
||
btn.textContent = btn.dataset.defaultLabel || defaultLabel;
|
||
btn._labelResetTimer = null;
|
||
}, timeout);
|
||
}
|
||
|
||
window.ccCopyMermaidDiagram = async function (btn, event) {
|
||
event?.preventDefault();
|
||
event?.stopPropagation();
|
||
const wrapper = btn?.closest?.('.mermaid-code-block');
|
||
if (!wrapper) return;
|
||
btn.disabled = true;
|
||
try {
|
||
const svgText = await getMermaidSvgText(wrapper);
|
||
if (!svgText) throw new Error('暂无可复制图形');
|
||
let copied = false;
|
||
try {
|
||
const pngBlob = await svgTextToPngBlob(svgText);
|
||
copied = await copyBlobToClipboard(pngBlob, 'image/png');
|
||
} catch {}
|
||
if (!copied) {
|
||
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
|
||
try { copied = await copyBlobToClipboard(svgBlob, 'image/svg+xml'); } catch {}
|
||
}
|
||
if (copied) {
|
||
showToast('图形已复制');
|
||
setTemporaryButtonLabel(btn, '已复制', '复制图');
|
||
} else {
|
||
await copyTextToClipboard(svgText, '图形 SVG 已复制');
|
||
setTemporaryButtonLabel(btn, '已复制', '复制图');
|
||
}
|
||
} catch (error) {
|
||
showToast(error?.message || '复制图形失败');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
|
||
function downloadBlob(blob, filename) {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.rel = 'noopener';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
window.ccDownloadMermaidDiagram = async function (btn, event) {
|
||
event?.preventDefault();
|
||
event?.stopPropagation();
|
||
const wrapper = btn?.closest?.('.mermaid-code-block');
|
||
if (!wrapper) return;
|
||
btn.disabled = true;
|
||
try {
|
||
const svgText = await getMermaidSvgText(wrapper);
|
||
if (!svgText) throw new Error('暂无可下载图形');
|
||
const cid = wrapper.dataset.cid || Date.now();
|
||
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
|
||
downloadBlob(blob, `mermaid-${cid}.svg`);
|
||
showToast('图形已下载');
|
||
setTemporaryButtonLabel(btn, '已下载', '下载图');
|
||
} catch (error) {
|
||
showToast(error?.message || '下载图形失败');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
|
||
// --- WebSocket ---
|
||
function isBrowserOnline() {
|
||
return !('onLine' in navigator) || navigator.onLine;
|
||
}
|
||
|
||
function canConnectWs() {
|
||
return !isPageUnloading && isBrowserOnline();
|
||
}
|
||
|
||
function clearReconnectTimer() {
|
||
if (!reconnectTimer) return;
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
|
||
function connect() {
|
||
if (!canConnectWs()) return;
|
||
if (ws && ws.readyState <= 1) return;
|
||
clearReconnectTimer();
|
||
|
||
const socket = new WebSocket(WS_URL);
|
||
ws = socket;
|
||
wsAuthenticated = false;
|
||
|
||
socket.onopen = () => {
|
||
if (ws !== socket) return;
|
||
reconnectAttempts = 0;
|
||
clearReconnectTimer();
|
||
if (authToken) send({ type: 'auth', token: authToken });
|
||
};
|
||
|
||
socket.onmessage = (e) => {
|
||
if (ws !== socket) return;
|
||
let msg;
|
||
try { msg = JSON.parse(e.data); } catch { return; }
|
||
try {
|
||
handleServerMessage(msg);
|
||
} catch (err) {
|
||
console.error('[cc-web] failed to handle server message', err, msg);
|
||
if (activeSessionLoad) {
|
||
releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true });
|
||
}
|
||
}
|
||
};
|
||
|
||
socket.onclose = () => {
|
||
if (ws !== socket) return;
|
||
ws = null;
|
||
wsAuthenticated = false;
|
||
if (activeSessionLoad?.sessionId && !isPageUnloading) {
|
||
pendingSessionSwitchRequest = {
|
||
sessionId: activeSessionLoad.sessionId,
|
||
blocking: activeSessionLoad.blocking,
|
||
label: sessionLoadingLabel?.textContent || '',
|
||
requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
|
||
};
|
||
}
|
||
clearSessionLoading();
|
||
scheduleReconnect();
|
||
};
|
||
socket.onerror = () => {};
|
||
}
|
||
|
||
function send(data) {
|
||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
if (!canConnectWs()) return;
|
||
if (reconnectTimer) return;
|
||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||
reconnectAttempts++;
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connect();
|
||
}, delay);
|
||
}
|
||
|
||
// --- Server Message Handler ---
|
||
function isCurrentSessionEvent(msg) {
|
||
return !msg.sessionId || msg.sessionId === currentSessionId;
|
||
}
|
||
|
||
function ensureGeneratingForEvent(msg) {
|
||
if (!isCurrentSessionEvent(msg)) return false;
|
||
const targetSessionId = msg.sessionId || currentSessionId || null;
|
||
if (!isGenerating || generatingSessionId !== targetSessionId || !document.getElementById('streaming-msg')) {
|
||
return startGenerating(targetSessionId);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function handleServerMessage(msg) {
|
||
switch (msg.type) {
|
||
case 'auth_result':
|
||
if (msg.success) {
|
||
const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId;
|
||
authToken = msg.token;
|
||
wsAuthenticated = true;
|
||
localStorage.setItem('cc-web-token', msg.token);
|
||
document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
|
||
loginOverlay.hidden = true;
|
||
app.hidden = false;
|
||
flushPendingSessionSwitch();
|
||
send({ type: 'get_codex_config' });
|
||
// Check if must change password
|
||
if (msg.mustChangePassword) {
|
||
showForceChangePassword();
|
||
} else {
|
||
pendingInitialSessionLoad = shouldLoadInitialSession;
|
||
}
|
||
} else {
|
||
pendingSessionSwitchRequest = null;
|
||
clearSessionLoading();
|
||
authToken = null;
|
||
wsAuthenticated = false;
|
||
localStorage.removeItem('cc-web-token');
|
||
document.dispatchEvent(new CustomEvent('cc-web-auth-failed'));
|
||
loginOverlay.hidden = false;
|
||
app.hidden = true;
|
||
if (msg.banned) {
|
||
loginError.textContent = '该 IP 已被永久封禁';
|
||
loginError.hidden = false;
|
||
loginPassword.disabled = true;
|
||
loginForm.querySelector('button[type="submit"]').disabled = true;
|
||
} else {
|
||
loginError.textContent = '密码错误';
|
||
loginError.hidden = false;
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'session_list':
|
||
sessions = Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : [];
|
||
reconcileSessionCacheWithSessions();
|
||
renderSessionList();
|
||
if (currentSessionId) {
|
||
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
|
||
}
|
||
if (pendingInitialSessionLoad) {
|
||
pendingInitialSessionLoad = false;
|
||
initialSessionListHandled = true;
|
||
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
|
||
} else if (currentSessionId && !getSessionMeta(currentSessionId)) {
|
||
initialSessionListHandled = true;
|
||
resetChatView(currentAgent);
|
||
} else {
|
||
initialSessionListHandled = true;
|
||
}
|
||
break;
|
||
|
||
case 'session_info':
|
||
const snapshot = normalizeSessionSnapshot(msg);
|
||
const activeLoad = activeSessionLoad;
|
||
const pendingNewSession = pendingNewSessionRequest;
|
||
const messageRequestId = String(msg.requestId || '');
|
||
const looksLikeCreatedSession = Array.isArray(msg.messages)
|
||
&& msg.messages.length === 0
|
||
&& !msg.historyPending
|
||
&& !msg.isRunning;
|
||
const matchesPendingNewSessionFallback = !!(pendingNewSession
|
||
&& !messageRequestId
|
||
&& looksLikeCreatedSession
|
||
&& snapshot.sessionId
|
||
&& snapshot.sessionId !== currentSessionId
|
||
&& snapshot.agent === pendingNewSession.agent
|
||
&& (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd)
|
||
&& (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode));
|
||
const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
|
||
&& (!activeLoad.requestId || activeLoad.requestId === messageRequestId));
|
||
const matchesPendingNewSession = !!(pendingNewSession
|
||
&& ((!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId)
|
||
|| matchesPendingNewSessionFallback));
|
||
const canSwitchToSessionInfo = matchesActiveLoad
|
||
|| matchesPendingNewSession
|
||
|| msg.sessionId === currentSessionId
|
||
|| (!currentSessionId && !activeLoad && !pendingNewSession)
|
||
|| (!messageRequestId && !activeLoad && !pendingNewSession);
|
||
mergeSessionListSnapshot(snapshot);
|
||
if (matchesActiveLoad) {
|
||
activeSessionLoad.snapshot = snapshot;
|
||
}
|
||
if (!canSwitchToSessionInfo) {
|
||
if (!msg.historyPending) cacheSessionSnapshot(snapshot);
|
||
renderSessionList();
|
||
break;
|
||
}
|
||
if (matchesPendingNewSession) {
|
||
pendingNewSessionRequest = null;
|
||
if (!matchesActiveLoad) clearSessionLoading();
|
||
}
|
||
applySessionSnapshot(snapshot, {
|
||
immediate: isBlockingSessionLoad(msg.sessionId),
|
||
suppressUnreadToast: false,
|
||
preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning,
|
||
});
|
||
if (msg.sessionId === currentSessionId) {
|
||
setCurrentSessionRunningState(!!msg.isRunning);
|
||
}
|
||
if (!msg.historyPending) {
|
||
if (matchesActiveLoad) {
|
||
finalizeLoadedSession(msg.sessionId);
|
||
} else {
|
||
cacheSessionSnapshot(snapshot);
|
||
finishSessionSwitch(msg.sessionId);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'session_history_chunk':
|
||
if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
|
||
const blocking = isBlockingSessionLoad(msg.sessionId);
|
||
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
|
||
activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages);
|
||
}
|
||
prependHistoryMessages(msg.messages || [], {
|
||
preserveScroll: !blocking,
|
||
skipScrollbar: blocking,
|
||
baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
|
||
});
|
||
if (!msg.remaining) {
|
||
finalizeLoadedSession(msg.sessionId);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'session_message':
|
||
if (msg.sessionId && msg.message) {
|
||
const isCrossConversationReply = !!msg.message.crossConversation?.replyToRequestId;
|
||
updateCachedSession(msg.sessionId, (snapshot) => {
|
||
snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
|
||
snapshot.messages.push(deepClone(msg.message));
|
||
snapshot.updated = msg.message.timestamp || new Date().toISOString();
|
||
if (isCrossConversationReply) {
|
||
snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1);
|
||
snapshot.pendingReplyCount = Math.max(0, Number(snapshot.pendingReplyCount || 0) - 1);
|
||
snapshot.waitingOnChildren = Number(snapshot.pendingReplyCount || 0) > 0;
|
||
}
|
||
});
|
||
if (isCrossConversationReply) {
|
||
sessions = sessions.map((session) => {
|
||
if (session.id !== msg.sessionId) return session;
|
||
const pendingReplyCount = Math.max(0, Number(session.pendingReplyCount || 0) - 1);
|
||
const readyReplyCount = Math.max(0, Number(session.readyReplyCount || 0) - 1);
|
||
return {
|
||
...session,
|
||
readyReplyCount,
|
||
pendingReplyCount,
|
||
waitingOnChildren: pendingReplyCount > 0,
|
||
};
|
||
});
|
||
}
|
||
}
|
||
if (msg.sessionId === currentSessionId && msg.message) {
|
||
const messageIndex = currentSessionMessageCount;
|
||
currentSessionMessageCount += 1;
|
||
collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id));
|
||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||
if (welcome) welcome.remove();
|
||
const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom();
|
||
messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex));
|
||
followOutputIfNeeded(shouldFollow);
|
||
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
|
||
renderPendingCcwebPrompts({ scroll: false });
|
||
}
|
||
renderSessionList();
|
||
break;
|
||
|
||
case 'session_renamed':
|
||
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
|
||
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
|
||
if (msg.sessionId === currentSessionId) {
|
||
chatTitle.textContent = msg.title;
|
||
}
|
||
renderSessionList();
|
||
break;
|
||
|
||
case 'session_pinned':
|
||
applySessionPinnedState(msg.sessionId, msg.pinnedAt || null);
|
||
break;
|
||
|
||
case 'text_delta':
|
||
if (!ensureGeneratingForEvent(msg)) break;
|
||
pendingText += msg.text;
|
||
scheduleRender();
|
||
break;
|
||
|
||
case 'content_blocks':
|
||
if (!ensureGeneratingForEvent(msg)) break;
|
||
if (Array.isArray(msg.blocks)) {
|
||
if (!window.pendingContentBlocks) window.pendingContentBlocks = [];
|
||
window.pendingContentBlocks.push(...msg.blocks);
|
||
scheduleRender();
|
||
}
|
||
break;
|
||
|
||
case 'tool_start':
|
||
if (!ensureGeneratingForEvent(msg)) break;
|
||
if (isEmptyReasoningTool({ name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false })) break;
|
||
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
|
||
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
|
||
break;
|
||
|
||
case 'tool_update':
|
||
if (!ensureGeneratingForEvent(msg)) break;
|
||
if (!activeToolCalls.has(msg.toolUseId)) {
|
||
activeToolCalls.set(msg.toolUseId, {
|
||
name: msg.name,
|
||
input: msg.input,
|
||
kind: msg.kind || null,
|
||
meta: msg.meta || null,
|
||
result: msg.result,
|
||
done: false,
|
||
});
|
||
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null, msg.result);
|
||
}
|
||
activeToolCalls.get(msg.toolUseId).done = false;
|
||
if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name;
|
||
if (msg.input !== undefined) activeToolCalls.get(msg.toolUseId).input = msg.input;
|
||
if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
|
||
if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta;
|
||
activeToolCalls.get(msg.toolUseId).result = msg.result;
|
||
updateToolCall(msg.toolUseId, msg.result, false);
|
||
break;
|
||
|
||
case 'tool_end':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
if (!activeToolCalls.has(msg.toolUseId) && !isEmptyReasoningTool({ name: msg.name, input: msg.input, result: msg.result, kind: msg.kind || null, meta: msg.meta || null, done: true })) {
|
||
activeToolCalls.set(msg.toolUseId, {
|
||
name: msg.name,
|
||
input: msg.input,
|
||
kind: msg.kind || null,
|
||
meta: msg.meta || null,
|
||
result: msg.result,
|
||
done: true,
|
||
});
|
||
appendToolCall(msg.toolUseId, msg.name, msg.input, true, msg.kind || null, msg.meta || null, msg.result);
|
||
}
|
||
if (activeToolCalls.has(msg.toolUseId)) {
|
||
activeToolCalls.get(msg.toolUseId).done = true;
|
||
if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
|
||
if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta;
|
||
activeToolCalls.get(msg.toolUseId).result = msg.result;
|
||
}
|
||
updateToolCall(msg.toolUseId, msg.result, true);
|
||
break;
|
||
|
||
case 'cost':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`;
|
||
if (currentSessionId) {
|
||
updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalCost = msg.costUsd; });
|
||
}
|
||
break;
|
||
|
||
case 'usage':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
if (msg.totalUsage) {
|
||
const cacheText = msg.totalUsage.cachedInputTokens ? ` · cache ${msg.totalUsage.cachedInputTokens}` : '';
|
||
costDisplay.textContent = `in ${msg.totalUsage.inputTokens} · out ${msg.totalUsage.outputTokens}${cacheText}`;
|
||
if (currentSessionId) {
|
||
updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalUsage = deepClone(msg.totalUsage); });
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'done':
|
||
if (!isCurrentSessionEvent(msg)) {
|
||
if (msg.sessionId) {
|
||
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = false; });
|
||
}
|
||
break;
|
||
}
|
||
finishGenerating(msg.sessionId);
|
||
break;
|
||
|
||
case 'system_message':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
appendSystemMessage(msg.message, {
|
||
tone: msg.tone,
|
||
transient: msg.transient,
|
||
autoDismissMs: msg.autoDismissMs,
|
||
});
|
||
break;
|
||
|
||
case 'codex_app_steer_status':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
updateCodexAppSteerMessage(msg.clientMessageId, msg.status, msg.message);
|
||
break;
|
||
|
||
case 'codex_app_user_input_request':
|
||
if (msg.sessionId && msg.sessionId !== currentSessionId) {
|
||
showToast('Codex App 需要输入', msg.sessionId);
|
||
}
|
||
showCodexAppUserInputModal(msg);
|
||
break;
|
||
|
||
case 'codex_app_approval_request':
|
||
if (msg.sessionId && msg.sessionId !== currentSessionId) {
|
||
showToast('Codex App 需要审批', msg.sessionId);
|
||
}
|
||
showCodexAppApprovalModal(msg);
|
||
break;
|
||
|
||
case 'ccweb_mcp_child_agent_update':
|
||
applyCcwebMcpChildAgentUpdate(msg);
|
||
break;
|
||
|
||
case 'ccweb_prompt_user_update':
|
||
applyCcwebPromptUserUpdate(msg);
|
||
break;
|
||
|
||
case 'ccweb_prompt_user_remove':
|
||
applyCcwebPromptUserRemove(msg);
|
||
break;
|
||
|
||
case 'mcp_startup_status':
|
||
rememberMcpStartupStatus(msg.sessionId, msg.mcpStatus || msg.status);
|
||
showMcpStartupStatusToast(msg.mcpStatus || msg.status, msg.sessionId);
|
||
break;
|
||
|
||
case 'mode_changed':
|
||
if (msg.mode && MODE_LABELS[msg.mode]) {
|
||
currentMode = msg.mode;
|
||
modeSelect.value = currentMode;
|
||
localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
|
||
if (currentSessionId) {
|
||
updateCachedSession(currentSessionId, (snapshot) => { snapshot.mode = msg.mode; });
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'model_changed':
|
||
if (msg.model) {
|
||
currentModel = msg.model;
|
||
if (currentSessionId) {
|
||
updateCachedSession(currentSessionId, (snapshot) => { snapshot.model = msg.model; });
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'resume_generating':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
// Server has an active process for this session — resume streaming
|
||
setCurrentSessionRunningState(true);
|
||
if (!isGenerating || !document.getElementById('streaming-msg')) {
|
||
startGenerating(msg.sessionId || currentSessionId);
|
||
} else {
|
||
updateGenerationControls();
|
||
toolGroupCount = 0;
|
||
hasGrouped = false;
|
||
activeToolCalls.clear();
|
||
activeTodoCallTargets.clear();
|
||
const toolsDiv = document.querySelector('#streaming-msg .msg-tools');
|
||
if (toolsDiv) toolsDiv.innerHTML = '';
|
||
}
|
||
pendingText = msg.text || '';
|
||
flushRender();
|
||
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
||
const mergedCollabTool = mergeCollabAgentTools(msg.toolCalls);
|
||
const resumeToolCalls = [
|
||
...(mergedCollabTool ? [mergedCollabTool] : []),
|
||
...msg.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'),
|
||
];
|
||
for (const tc of resumeToolCalls) {
|
||
activeToolCalls.set(tc.id, {
|
||
name: tc.name,
|
||
input: tc.input,
|
||
result: tc.result,
|
||
kind: tc.kind || null,
|
||
meta: tc.meta || null,
|
||
done: tc.done,
|
||
});
|
||
appendToolCall(tc.id, tc.name, tc.input, tc.done, tc.kind || null, tc.meta || null);
|
||
if (tc.result !== undefined && tc.result !== null) {
|
||
updateToolCall(tc.id, tc.result, !!tc.done);
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'error':
|
||
if (!isCurrentSessionEvent(msg)) break;
|
||
if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
|
||
const request = pendingNewSessionRequest;
|
||
pendingNewSessionRequest = null;
|
||
showSimpleConfirm({
|
||
title: '目录不存在',
|
||
message: `${msg.cwd || request.cwd}\n\n要先创建这个目录再进入新会话吗?`,
|
||
confirmText: '创建目录',
|
||
cancelText: '返回修改',
|
||
onConfirm: () => {
|
||
pendingNewSessionRequest = { ...request };
|
||
send({
|
||
type: 'new_session',
|
||
cwd: request.cwd,
|
||
agent: request.agent,
|
||
mode: request.mode,
|
||
model: request.model,
|
||
title: request.title,
|
||
branchSourceSessionId: request.branchSourceSessionId,
|
||
branchMessageIndex: request.branchMessageIndex,
|
||
createCwd: true,
|
||
requestId: request.requestId,
|
||
});
|
||
},
|
||
onCancel: () => {
|
||
showNewSessionModal({
|
||
agent: request.agent,
|
||
cwd: request.rawCwd || request.cwd,
|
||
mode: request.mode,
|
||
model: request.model,
|
||
title: request.title,
|
||
branchSourceSessionId: request.branchSourceSessionId,
|
||
branchMessageIndex: request.branchMessageIndex,
|
||
});
|
||
},
|
||
});
|
||
clearSessionLoading();
|
||
if (!isGenerating && currentSessionId) {
|
||
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
|
||
}
|
||
break;
|
||
}
|
||
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
|
||
appendError(msg.message, {
|
||
transient: msg.transient,
|
||
autoDismissMs: msg.autoDismissMs,
|
||
});
|
||
clearSessionLoading();
|
||
if (!isGenerating && currentSessionId) {
|
||
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
|
||
}
|
||
if (isGenerating) finishGenerating();
|
||
break;
|
||
|
||
case 'notify_config':
|
||
if (typeof _onNotifyConfig === 'function') _onNotifyConfig(msg.config);
|
||
// Update summary in parent settings panel if visible
|
||
if (msg.config) {
|
||
const provider = msg.config.provider || 'off';
|
||
const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
|
||
const summaryOn = msg.config.summary?.enabled ? '摘要已启用' : '摘要关闭';
|
||
const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`;
|
||
document.querySelectorAll('[data-notify-summary]').forEach(el => { el.textContent = meta; });
|
||
}
|
||
break;
|
||
|
||
case 'notify_test_result':
|
||
if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg);
|
||
break;
|
||
|
||
case 'model_config':
|
||
if (typeof _onModelConfig === 'function') _onModelConfig(msg.config);
|
||
break;
|
||
|
||
case 'codex_config':
|
||
codexConfigCache = msg.config || null;
|
||
if (typeof _onCodexConfig === 'function') _onCodexConfig(msg.config);
|
||
break;
|
||
|
||
case 'fetch_models_result':
|
||
if (typeof _onFetchModelsResult === 'function') _onFetchModelsResult(msg);
|
||
break;
|
||
|
||
case 'background_done':
|
||
// A background task completed (browser was disconnected or viewing another session)
|
||
showToast(`「${msg.title}」任务完成`, msg.sessionId);
|
||
showBrowserNotification(msg.title);
|
||
if (msg.sessionId === currentSessionId) {
|
||
// Reload current session to show completed response
|
||
openSession(msg.sessionId, { forceSync: true, blocking: false });
|
||
} else {
|
||
send({ type: 'list_sessions' });
|
||
}
|
||
break;
|
||
|
||
case 'password_changed':
|
||
handlePasswordChanged(msg);
|
||
break;
|
||
|
||
case 'native_sessions':
|
||
if (typeof _onNativeSessions === 'function') _onNativeSessions(msg.groups || []);
|
||
break;
|
||
|
||
case 'codex_sessions':
|
||
if (typeof _onCodexSessions === 'function') _onCodexSessions(msg.sessions || []);
|
||
break;
|
||
|
||
case 'cwd_suggestions':
|
||
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg);
|
||
break;
|
||
|
||
case 'composer_suggestions':
|
||
handleComposerSuggestions(msg);
|
||
break;
|
||
|
||
case 'update_info':
|
||
if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// --- Generating State ---
|
||
function startGenerating(sessionId = currentSessionId) {
|
||
const targetSessionId = sessionId || currentSessionId || null;
|
||
if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
|
||
isGenerating = true;
|
||
generatingSessionId = targetSessionId;
|
||
setCurrentSessionRunningState(true);
|
||
pendingText = '';
|
||
window.pendingContentBlocks = [];
|
||
activeToolCalls.clear();
|
||
activeTodoCallTargets.clear();
|
||
toolGroupCount = 0;
|
||
hasGrouped = false;
|
||
updateNoteModeUI();
|
||
renderPendingNotes({ scroll: false });
|
||
// 不禁用输入框,允许用户继续输入(但无法发送)
|
||
|
||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||
if (welcome) welcome.remove();
|
||
|
||
const msgEl = createMsgElement('assistant', '');
|
||
msgEl.id = 'streaming-msg';
|
||
if (targetSessionId) msgEl.dataset.sessionId = targetSessionId;
|
||
// 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器
|
||
const bubble = msgEl.querySelector('.msg-bubble');
|
||
bubble.innerHTML = '';
|
||
const textDiv = document.createElement('div');
|
||
textDiv.className = 'msg-text';
|
||
textDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||
const toolsDiv = document.createElement('div');
|
||
toolsDiv.className = 'msg-tools';
|
||
bubble.appendChild(textDiv);
|
||
bubble.appendChild(toolsDiv);
|
||
syncAssistantLastSectionButton(msgEl);
|
||
messagesDiv.appendChild(msgEl);
|
||
scrollToBottom();
|
||
return true;
|
||
}
|
||
|
||
function finishGenerating(sessionId) {
|
||
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
|
||
const hasPersistedAssistantMessage = !!(
|
||
pendingText
|
||
|| (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0)
|
||
);
|
||
isGenerating = false;
|
||
generatingSessionId = null;
|
||
updateNoteModeUI();
|
||
setCurrentSessionRunningState(false);
|
||
msgInput.focus();
|
||
|
||
if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
|
||
flushRender();
|
||
}
|
||
|
||
window.pendingContentBlocks = [];
|
||
|
||
const typing = document.querySelector('.typing-indicator');
|
||
if (typing) typing.remove();
|
||
|
||
const streamEl = document.getElementById('streaming-msg');
|
||
if (streamEl) {
|
||
// 若本轮出现过父目录,把末尾散落的 .tool-call 也一并收入同一父节点
|
||
if (hasGrouped) {
|
||
const toolsDiv = streamEl.querySelector('.msg-tools');
|
||
if (toolsDiv) {
|
||
const loose = Array.from(toolsDiv.children).filter(isGroupableToolCall);
|
||
if (loose.length > 0) {
|
||
let group = toolsDiv.querySelector(':scope > .tool-group');
|
||
if (!group) {
|
||
group = document.createElement('details');
|
||
group.className = 'tool-group';
|
||
const gs = document.createElement('summary');
|
||
gs.className = 'tool-group-summary';
|
||
group.appendChild(gs);
|
||
const inner = document.createElement('div');
|
||
inner.className = 'tool-group-inner';
|
||
group.appendChild(inner);
|
||
toolsDiv.insertBefore(group, toolsDiv.firstChild);
|
||
}
|
||
const inner = group.querySelector('.tool-group-inner');
|
||
loose.forEach(c => inner.appendChild(c));
|
||
_refreshGroupSummary(group);
|
||
}
|
||
}
|
||
}
|
||
streamEl.removeAttribute('id');
|
||
if (hasPersistedAssistantMessage && currentSessionId) {
|
||
const messageIndex = currentSessionMessageCount;
|
||
currentSessionMessageCount += 1;
|
||
markSessionMessageElement(streamEl, messageIndex);
|
||
}
|
||
syncAssistantLastSectionButton(streamEl);
|
||
}
|
||
|
||
if (sessionId) {
|
||
migratePendingNotesToSession(sessionId, currentAgent);
|
||
migrateQueuedMessagesToSession(sessionId, currentAgent);
|
||
}
|
||
pendingText = '';
|
||
activeToolCalls.clear();
|
||
activeTodoCallTargets.clear();
|
||
toolGroupCount = 0;
|
||
hasGrouped = false;
|
||
renderPendingNotes();
|
||
scheduleQueuedMessageDrain();
|
||
}
|
||
|
||
// --- Rendering ---
|
||
function scheduleRender() {
|
||
if (renderTimer) return;
|
||
renderTimer = setTimeout(() => {
|
||
renderTimer = null;
|
||
flushRender();
|
||
}, RENDER_DEBOUNCE);
|
||
}
|
||
|
||
function flushRender() {
|
||
const streamEl = document.getElementById('streaming-msg');
|
||
if (!streamEl) return;
|
||
const bubble = streamEl.querySelector('.msg-bubble');
|
||
if (!bubble) return;
|
||
const shouldFollow = isNearBottom();
|
||
|
||
if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) {
|
||
bubble.innerHTML = '';
|
||
renderAssistantContent(bubble, window.pendingContentBlocks);
|
||
} else if (pendingText) {
|
||
let textDiv = bubble.querySelector('.msg-text');
|
||
if (!textDiv) { textDiv = bubble; }
|
||
setRenderedMarkdown(textDiv, pendingText);
|
||
}
|
||
syncAssistantLastSectionButton(streamEl);
|
||
followOutputIfNeeded(shouldFollow);
|
||
}
|
||
|
||
function renderMarkdown(text) {
|
||
if (!text) return '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||
try { return marked.parse(text); }
|
||
catch { return escapeHtml(text); }
|
||
}
|
||
|
||
function codexAppSteerStatusLabel(status) {
|
||
if (status === 'inserted') return '已插入';
|
||
if (status === 'failed') return '插入失败';
|
||
return '引导中...';
|
||
}
|
||
|
||
function setCodexAppSteerStatusElement(element, status, message) {
|
||
if (!element) return false;
|
||
const normalized = ['pending', 'inserted', 'failed'].includes(status) ? status : 'pending';
|
||
element.classList.add('codex-steer-message');
|
||
element.classList.toggle('codex-steer-pending', normalized === 'pending');
|
||
element.classList.toggle('codex-steer-inserted', normalized === 'inserted');
|
||
element.classList.toggle('codex-steer-failed', normalized === 'failed');
|
||
const bubble = element.querySelector('.msg-bubble');
|
||
if (!bubble) return false;
|
||
let statusEl = bubble.querySelector('.codex-steer-status');
|
||
if (!statusEl) {
|
||
statusEl = document.createElement('div');
|
||
statusEl.className = 'codex-steer-status';
|
||
bubble.appendChild(statusEl);
|
||
}
|
||
statusEl.dataset.status = normalized;
|
||
statusEl.textContent = message || codexAppSteerStatusLabel(normalized);
|
||
return true;
|
||
}
|
||
|
||
function updateCodexAppSteerMessage(clientMessageId, status, message) {
|
||
const id = String(clientMessageId || '').trim();
|
||
if (!id) return false;
|
||
const indexed = userMessageIndex.get(id);
|
||
const element = indexed?.element || messagesDiv.querySelector(`[data-message-id="${cssEscape(id)}"]`);
|
||
return setCodexAppSteerStatusElement(element, status, message);
|
||
}
|
||
|
||
function scheduleTransientMessageRemoval(element, timeoutMs) {
|
||
const ttl = Number(timeoutMs);
|
||
if (!element || !Number.isFinite(ttl) || ttl <= 0) return;
|
||
window.setTimeout(() => {
|
||
if (!element || !element.isConnected) return;
|
||
element.classList.add('is-dismissing');
|
||
window.setTimeout(() => {
|
||
if (!element || !element.isConnected) return;
|
||
element.remove();
|
||
updateScrollbar();
|
||
}, 220);
|
||
}, ttl);
|
||
}
|
||
|
||
function normalizeMentionList(value) {
|
||
return Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : [];
|
||
}
|
||
|
||
function escapeHtmlAttr(value) {
|
||
return String(value || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
function shortPreviewText(text, maxLength = 140) {
|
||
const normalized = String(text || '').trim().replace(/\s+/g, ' ');
|
||
if (!normalized) return '';
|
||
return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized;
|
||
}
|
||
|
||
function mentionDependencyStateLabel(state) {
|
||
return state === 'configured' ? '已配置' : state === 'declared' ? '未配置' : '';
|
||
}
|
||
|
||
function mentionDependencyLabel(dep) {
|
||
const name = dep?.value || dep?.name || dep?.server || '';
|
||
const state = mentionDependencyStateLabel(dep?.state);
|
||
return state ? `${name} · ${state}` : name;
|
||
}
|
||
|
||
function buildMentionTooltip(mention) {
|
||
const lines = [];
|
||
const title = mention.title || mention.name || mention.label || '';
|
||
const description = shortPreviewText(mention.description || '');
|
||
if (title) lines.push(title);
|
||
if (description) lines.push(description);
|
||
if (mention.defaultPromptPreview) lines.push(`默认提示: ${shortPreviewText(mention.defaultPromptPreview, 180)}`);
|
||
const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : [];
|
||
for (const dep of dependencies.slice(0, 4)) {
|
||
const label = mentionDependencyLabel(dep);
|
||
if (label) lines.push(`MCP: ${label}`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function createMentionChip(mention) {
|
||
const chip = document.createElement('div');
|
||
const kind = String(mention.kind || '').trim() || 'mention';
|
||
chip.className = `msg-mention-chip kind-${kind}`;
|
||
if (mention.brandColor) chip.style.setProperty('--mention-accent', mention.brandColor);
|
||
const tooltip = buildMentionTooltip(mention);
|
||
if (tooltip) chip.title = tooltip;
|
||
|
||
if (kind === 'skill') {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'msg-mention-badge';
|
||
if (mention.iconSmall && /^https?:\/\//i.test(String(mention.iconSmall))) {
|
||
const img = document.createElement('img');
|
||
img.src = mention.iconSmall;
|
||
img.alt = mention.title || mention.name || 'skill';
|
||
img.loading = 'lazy';
|
||
badge.appendChild(img);
|
||
} else {
|
||
badge.textContent = 'Skill';
|
||
}
|
||
chip.appendChild(badge);
|
||
}
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'msg-mention-body';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'msg-mention-title';
|
||
if (kind === 'skill') {
|
||
title.textContent = mention.title || mention.name || mention.label || '';
|
||
} else if (kind === 'prompt') {
|
||
title.textContent = mention.title || mention.label || mention.name || '';
|
||
} else {
|
||
title.textContent = mention.label || mention.name || '';
|
||
}
|
||
body.appendChild(title);
|
||
|
||
const descriptionText = mention.description || (kind === 'prompt' ? 'Prompt 模板' : '');
|
||
if (descriptionText) {
|
||
const desc = document.createElement('div');
|
||
desc.className = 'msg-mention-desc';
|
||
desc.textContent = shortPreviewText(descriptionText, kind === 'skill' ? 92 : 72);
|
||
body.appendChild(desc);
|
||
}
|
||
|
||
const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : [];
|
||
if (dependencies.length > 0) {
|
||
const meta = document.createElement('div');
|
||
meta.className = 'msg-mention-meta';
|
||
dependencies.slice(0, 2).forEach((dep) => {
|
||
const pill = document.createElement('span');
|
||
pill.className = `msg-mention-pill state-${dep.state || 'declared'}`;
|
||
pill.textContent = mentionDependencyLabel(dep);
|
||
meta.appendChild(pill);
|
||
});
|
||
body.appendChild(meta);
|
||
}
|
||
|
||
chip.appendChild(body);
|
||
return chip;
|
||
}
|
||
|
||
function renderComposerMentionsStrip(meta) {
|
||
const mentions = normalizeMentionList(meta?.composerMentions);
|
||
if (mentions.length === 0) return null;
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'msg-mentions';
|
||
mentions.forEach((mention) => wrap.appendChild(createMentionChip(mention)));
|
||
return wrap;
|
||
}
|
||
|
||
function createMsgElement(role, content, attachments = [], meta = {}) {
|
||
const div = document.createElement('div');
|
||
const isCrossConversation = !!meta.crossConversation;
|
||
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
|
||
const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply;
|
||
const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user');
|
||
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`;
|
||
if (role === 'user') {
|
||
div.id = `hapi-message-${resolvedMessageId}`;
|
||
div.dataset.messageId = resolvedMessageId;
|
||
}
|
||
|
||
if (role === 'system') {
|
||
const tone = String(meta.tone || 'neutral').trim() || 'neutral';
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'msg-bubble';
|
||
bubble.dataset.tone = tone;
|
||
const text = document.createElement('span');
|
||
text.className = 'system-message-text';
|
||
text.textContent = content;
|
||
bubble.appendChild(text);
|
||
|
||
const transient = !!meta.transient;
|
||
if (transient) {
|
||
div.classList.add('transient');
|
||
}
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.className = 'system-message-close';
|
||
closeBtn.title = '关闭提示';
|
||
closeBtn.setAttribute('aria-label', '关闭提示');
|
||
closeBtn.textContent = '×';
|
||
closeBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
div.remove();
|
||
updateScrollbar();
|
||
});
|
||
bubble.appendChild(closeBtn);
|
||
div.appendChild(bubble);
|
||
if (transient) {
|
||
scheduleTransientMessageRemoval(div, meta.autoDismissMs || 6000);
|
||
}
|
||
return div;
|
||
}
|
||
|
||
const avatar = document.createElement('div');
|
||
avatar.className = 'msg-avatar';
|
||
if (isCrossConversation) {
|
||
avatar.textContent = isCrossConversationReply ? '↩' : '↗';
|
||
} else if (role === 'user') {
|
||
avatar.textContent = 'U';
|
||
} else if (isCodexLikeAgent(currentAgent)) {
|
||
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'}">`;
|
||
} else {
|
||
avatar.innerHTML = `<img src="/claude.png" width="24" height="24" style="display:block;" alt="Claude">`;
|
||
}
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'msg-bubble';
|
||
let crossConversationReplyCollapseKey = '';
|
||
let crossConversationReplyToggle = null;
|
||
let crossConversationReplyBody = null;
|
||
|
||
const applyCrossConversationReplyCollapseState = (collapsed) => {
|
||
if (!crossConversationReplyToggle || !crossConversationReplyBody) return;
|
||
div.classList.toggle('cross-conversation-collapsed', collapsed);
|
||
bubble.dataset.collapsed = collapsed ? 'true' : 'false';
|
||
crossConversationReplyBody.hidden = collapsed;
|
||
crossConversationReplyToggle.textContent = collapsed ? '展开' : '收起';
|
||
crossConversationReplyToggle.title = collapsed ? '展开返回消息' : '收起返回消息';
|
||
crossConversationReplyToggle.setAttribute('aria-label', collapsed ? '展开返回消息' : '收起返回消息');
|
||
crossConversationReplyToggle.setAttribute('aria-expanded', String(!collapsed));
|
||
updateScrollbar();
|
||
};
|
||
|
||
if (canCollapseCrossConversationReply) {
|
||
crossConversationReplyCollapseKey = getCrossConversationReplyCollapseKey(meta);
|
||
if (crossConversationReplyCollapseKey) {
|
||
div.dataset.crossConversationReplyKey = crossConversationReplyCollapseKey;
|
||
}
|
||
}
|
||
|
||
if (isCrossConversation) {
|
||
const source = meta.crossConversation || {};
|
||
const sourceTitle = source.sourceTitle || '未命名对话';
|
||
const sourceId = source.sourceSessionId || '';
|
||
const sourceMeta = document.createElement('div');
|
||
sourceMeta.className = 'cross-conversation-meta';
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'cross-conversation-label';
|
||
label.textContent = isCrossConversationReply
|
||
? `来自「${sourceTitle}」的回复`
|
||
: `来自「${sourceTitle}」的对话`;
|
||
sourceMeta.appendChild(label);
|
||
|
||
if (sourceId) {
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.type = 'button';
|
||
copyBtn.className = 'cross-conversation-id-btn';
|
||
copyBtn.textContent = `ID ${shortSessionId(sourceId)}`;
|
||
copyBtn.title = `复制来源会话 ID\n${sourceId}`;
|
||
copyBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
copyTextToClipboard(sourceId, '来源会话 ID 已复制');
|
||
});
|
||
sourceMeta.appendChild(copyBtn);
|
||
}
|
||
|
||
if (canCollapseCrossConversationReply) {
|
||
const replyTimeText = formatCrossConversationReplyTime(meta.timestamp || source.processedAt || source.sentAt);
|
||
if (replyTimeText) {
|
||
const time = document.createElement('span');
|
||
time.className = 'cross-conversation-time';
|
||
time.textContent = replyTimeText;
|
||
sourceMeta.appendChild(time);
|
||
}
|
||
|
||
crossConversationReplyToggle = document.createElement('button');
|
||
crossConversationReplyToggle.type = 'button';
|
||
crossConversationReplyToggle.className = 'cross-conversation-collapse-btn';
|
||
crossConversationReplyToggle.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
const collapsed = !div.classList.contains('cross-conversation-collapsed');
|
||
setCrossConversationReplyCollapsed(crossConversationReplyCollapseKey, collapsed);
|
||
applyCrossConversationReplyCollapseState(collapsed);
|
||
});
|
||
sourceMeta.appendChild(crossConversationReplyToggle);
|
||
}
|
||
|
||
bubble.appendChild(sourceMeta);
|
||
}
|
||
|
||
if (role === 'user') {
|
||
if (content) {
|
||
const textNode = document.createElement('div');
|
||
textNode.className = 'msg-text';
|
||
textNode.style.whiteSpace = 'pre-wrap';
|
||
textNode.textContent = content;
|
||
bubble.appendChild(textNode);
|
||
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.type = 'button';
|
||
copyBtn.className = 'msg-copy-btn';
|
||
copyBtn.title = '复制用户消息';
|
||
copyBtn.setAttribute('aria-label', '复制用户消息');
|
||
copyBtn.innerHTML = `
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="9" y="9" width="10" height="10" rx="2"></rect>
|
||
<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"></path>
|
||
</svg>
|
||
`;
|
||
copyBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
copyTextToClipboard(content, '用户消息已复制');
|
||
});
|
||
bubble.appendChild(copyBtn);
|
||
}
|
||
if (attachments.length > 0) {
|
||
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
|
||
}
|
||
const mentionsStrip = renderComposerMentionsStrip(meta);
|
||
if (mentionsStrip) bubble.appendChild(mentionsStrip);
|
||
} else {
|
||
const assistantContentTarget = canCollapseCrossConversationReply ? document.createElement('div') : bubble;
|
||
if (canCollapseCrossConversationReply) {
|
||
assistantContentTarget.className = 'cross-conversation-reply-body';
|
||
crossConversationReplyBody = assistantContentTarget;
|
||
}
|
||
renderAssistantContent(assistantContentTarget, content);
|
||
if (attachments.length > 0) {
|
||
assistantContentTarget.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
|
||
}
|
||
if (canCollapseCrossConversationReply) {
|
||
bubble.appendChild(assistantContentTarget);
|
||
}
|
||
}
|
||
|
||
hydrateAttachmentPreviews(bubble, attachments);
|
||
div.appendChild(avatar);
|
||
div.appendChild(bubble);
|
||
if (role === 'assistant') {
|
||
syncAssistantLastSectionButton(div);
|
||
}
|
||
if (canCollapseCrossConversationReply) {
|
||
applyCrossConversationReplyCollapseState(isCrossConversationReplyCollapsed(crossConversationReplyCollapseKey));
|
||
}
|
||
if (role === 'user' && meta.codexAppSteerStatus) {
|
||
setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage);
|
||
}
|
||
if (role === 'user') {
|
||
registerUserMessage(resolvedMessageId, div, content);
|
||
}
|
||
return div;
|
||
}
|
||
|
||
function renderAssistantContent(bubble, content) {
|
||
if (!content) return;
|
||
|
||
if (typeof content === 'string') {
|
||
// 检测并提取 JSON 格式的 todo_list(可能在代码块中)
|
||
const jsonMatch = content.match(/```json\s*(\{[\s\S]*?"type"\s*:\s*"todo_list"[\s\S]*?\})\s*```/);
|
||
if (jsonMatch) {
|
||
try {
|
||
const parsed = JSON.parse(jsonMatch[1]);
|
||
if (parsed.type === 'todo_list') {
|
||
const before = content.substring(0, jsonMatch.index);
|
||
const after = content.substring(jsonMatch.index + jsonMatch[0].length);
|
||
if (before.trim()) {
|
||
const textDiv = document.createElement('div');
|
||
setRenderedMarkdown(textDiv, before, false);
|
||
bubble.appendChild(textDiv);
|
||
hydrateRenderedMarkdown(textDiv);
|
||
}
|
||
bubble.appendChild(createTodoListElement(parsed));
|
||
if (after.trim()) {
|
||
const textDiv = document.createElement('div');
|
||
setRenderedMarkdown(textDiv, after, false);
|
||
bubble.appendChild(textDiv);
|
||
hydrateRenderedMarkdown(textDiv);
|
||
}
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// 尝试直接解析 JSON
|
||
const trimmed = content.trim();
|
||
if (trimmed.startsWith('{') && trimmed.includes('"type"') && trimmed.includes('"todo_list"')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
if (parsed.type === 'todo_list') {
|
||
bubble.appendChild(createTodoListElement(parsed));
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
setRenderedMarkdown(bubble, content);
|
||
return;
|
||
}
|
||
|
||
if (Array.isArray(content)) {
|
||
content.forEach(block => {
|
||
if (block.type === 'text') {
|
||
const textDiv = document.createElement('div');
|
||
setRenderedMarkdown(textDiv, block.text || '', false);
|
||
bubble.appendChild(textDiv);
|
||
hydrateRenderedMarkdown(textDiv);
|
||
} else if (block.type === 'todo_list') {
|
||
bubble.appendChild(createTodoListElement(block));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
setRenderedMarkdown(bubble, String(content));
|
||
}
|
||
|
||
function createTodoListElement(block) {
|
||
const container = document.createElement('div');
|
||
container.className = 'todo-list-container';
|
||
container.dataset.todoId = block.id || '';
|
||
|
||
const list = document.createElement('ul');
|
||
list.className = 'todo-list';
|
||
|
||
if (Array.isArray(block.items)) {
|
||
block.items.forEach((item, index) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'todo-item' + (item.completed ? ' completed' : '');
|
||
|
||
const checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.className = 'todo-checkbox';
|
||
checkbox.checked = item.completed || false;
|
||
checkbox.dataset.index = index;
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'todo-text';
|
||
label.textContent = item.text || '';
|
||
|
||
li.appendChild(checkbox);
|
||
li.appendChild(label);
|
||
list.appendChild(li);
|
||
});
|
||
}
|
||
|
||
container.appendChild(list);
|
||
return container;
|
||
}
|
||
|
||
let renderEpoch = 0;
|
||
|
||
function toolKind(tool) {
|
||
return tool?.kind || tool?.meta?.kind || '';
|
||
}
|
||
|
||
function normalizeDisplayPath(filePath) {
|
||
const rawPath = typeof filePath === 'string' ? filePath.trim() : '';
|
||
if (!rawPath) return '';
|
||
const normalizedPath = rawPath.replace(/\\/g, '/');
|
||
const normalizedCwd = typeof currentCwd === 'string' ? currentCwd.trim().replace(/\\/g, '/') : '';
|
||
if (normalizedCwd && normalizedPath.startsWith(`${normalizedCwd}/`)) {
|
||
return normalizedPath.slice(normalizedCwd.length + 1);
|
||
}
|
||
return normalizedPath;
|
||
}
|
||
|
||
function getFileChangeDisplay(tool) {
|
||
const changes = Array.isArray(tool?.input?.changes) ? tool.input.changes : [];
|
||
const primaryChange = changes[0] || null;
|
||
const rawPath = tool?.meta?.subtitle || primaryChange?.path || tool?.input?.path || tool?.input?.file_path || '';
|
||
const filePath = normalizeDisplayPath(rawPath);
|
||
const rawKind = primaryChange?.kind || tool?.input?.kind || '';
|
||
let action = '修改';
|
||
if (rawKind === 'create' || rawKind === 'new') {
|
||
action = '创建';
|
||
} else if (rawKind === 'delete' || rawKind === 'remove') {
|
||
action = '删除';
|
||
} else if (rawKind === 'update' || rawKind === 'edit') {
|
||
action = '更新';
|
||
} else if (tool?.input?.new_string && tool?.input?.old_string) {
|
||
action = '更新';
|
||
}
|
||
return { action, filePath, changeCount: changes.length };
|
||
}
|
||
|
||
function toolTitle(tool) {
|
||
if (toolKind(tool) === 'file_change') {
|
||
const { action, filePath, changeCount } = getFileChangeDisplay(tool);
|
||
if (filePath && changeCount > 1) return `${action} ${filePath} 等 ${changeCount} 个文件`;
|
||
if (filePath) return `${action} ${filePath}`;
|
||
if (changeCount > 1) return `修改 ${changeCount} 个文件`;
|
||
return tool?.meta?.title || 'File Change';
|
||
}
|
||
if (tool?.meta?.title) return tool.meta.title;
|
||
return tool?.name || 'Tool';
|
||
}
|
||
|
||
function toolSubtitle(tool) {
|
||
if (toolKind(tool) === 'file_change') {
|
||
return '';
|
||
}
|
||
if (tool?.meta?.subtitle) return tool.meta.subtitle;
|
||
if (toolKind(tool) === 'command_execution') {
|
||
return tool?.input?.command || '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function stringifyToolValue(value) {
|
||
if (typeof value === 'string') return value;
|
||
if (value === null || value === undefined) return '';
|
||
try {
|
||
return JSON.stringify(value, null, 2);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function reasoningPartText(parts) {
|
||
if (!Array.isArray(parts)) return '';
|
||
return parts.map((part) => {
|
||
if (typeof part === 'string') return part;
|
||
if (typeof part?.text === 'string') return part.text;
|
||
return '';
|
||
}).filter(Boolean).join('\n');
|
||
}
|
||
|
||
function isEmptyReasoningTool(tool) {
|
||
if (toolKind(tool) !== 'reasoning') return false;
|
||
const resultText = stringifyToolValue(tool?.result).trim();
|
||
const input = tool?.input || {};
|
||
const inputText = [
|
||
reasoningPartText(input.content),
|
||
reasoningPartText(input.summary),
|
||
].filter(Boolean).join('\n').trim();
|
||
return !resultText && !inputText;
|
||
}
|
||
|
||
function toolStateLabel(tool, done) {
|
||
if (!done) return 'Running';
|
||
if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number') {
|
||
return `Exit ${tool.meta.exitCode}`;
|
||
}
|
||
return 'Done';
|
||
}
|
||
|
||
function toolStateClass(tool, done) {
|
||
if (!done) return 'running';
|
||
if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number' && tool.meta.exitCode !== 0) {
|
||
return 'error';
|
||
}
|
||
return 'done';
|
||
}
|
||
|
||
function applyToolSummary(summary, tool, done) {
|
||
summary.innerHTML = '';
|
||
const icon = document.createElement('span');
|
||
icon.className = `tool-call-icon ${done ? 'done' : 'running'}`;
|
||
|
||
const main = document.createElement('span');
|
||
main.className = 'tool-call-summary-main';
|
||
const label = document.createElement('span');
|
||
label.className = 'tool-call-label';
|
||
label.textContent = toolTitle(tool);
|
||
main.appendChild(label);
|
||
|
||
const subtitleText = toolSubtitle(tool);
|
||
if (subtitleText) {
|
||
const subtitle = document.createElement('span');
|
||
subtitle.className = 'tool-call-subtitle';
|
||
subtitle.textContent = subtitleText;
|
||
main.appendChild(subtitle);
|
||
}
|
||
|
||
const state = document.createElement('span');
|
||
state.className = `tool-call-state ${toolStateClass(tool, done)}`;
|
||
state.textContent = toolStateLabel(tool, done);
|
||
|
||
summary.appendChild(icon);
|
||
summary.appendChild(main);
|
||
summary.appendChild(state);
|
||
}
|
||
|
||
function buildStructuredToolSection(labelText, bodyText) {
|
||
const section = document.createElement('div');
|
||
section.className = 'tool-call-section';
|
||
const label = document.createElement('div');
|
||
label.className = 'tool-call-section-label';
|
||
label.textContent = labelText;
|
||
const pre = document.createElement('pre');
|
||
pre.className = 'tool-call-code';
|
||
pre.textContent = bodyText;
|
||
section.appendChild(label);
|
||
section.appendChild(pre);
|
||
return section;
|
||
}
|
||
|
||
function parseMaybeJsonObject(value) {
|
||
if (value && typeof value === 'object') return value;
|
||
if (typeof value !== 'string') return null;
|
||
const trimmed = value.trim();
|
||
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null;
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function summarizePrompt(prompt) {
|
||
const text = typeof prompt === 'string' ? prompt.trim().replace(/\s+/g, ' ') : '';
|
||
if (!text) return '';
|
||
return text.length > 140 ? `${text.slice(0, 140)}…` : text;
|
||
}
|
||
|
||
function normalizeCollabAgentAction(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) return '';
|
||
const name = raw.split(/[./]/).filter(Boolean).pop() || raw;
|
||
const normalized = name.toLowerCase().replace(/[\s_-]/g, '');
|
||
if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent';
|
||
if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent';
|
||
if (normalized === 'close' || normalized === 'closeagent') return 'close_agent';
|
||
if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
|
||
if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent';
|
||
return normalized;
|
||
}
|
||
|
||
function getCollabAgentAction(tool, data = null) {
|
||
const value = data?.tool || tool?.name || tool?.meta?.title || '';
|
||
return normalizeCollabAgentAction(value);
|
||
}
|
||
|
||
function normalizeCollabAgentData(tool) {
|
||
const inputData = effectiveObject(tool?.input);
|
||
const resultData = effectiveObject(tool?.result);
|
||
const merged = {
|
||
...inputData,
|
||
...resultData,
|
||
agentsStates: resultData.agentsStates || resultData.agents_states || inputData.agentsStates || inputData.agents_states || {},
|
||
receiverThreadIds: resultData.receiverThreadIds || resultData.receiver_thread_ids || resultData.targets || inputData.receiverThreadIds || inputData.receiver_thread_ids || inputData.targets || [],
|
||
prompt: inputData.prompt || resultData.prompt || '',
|
||
tool: inputData.tool || resultData.tool || tool?.name || '',
|
||
status: resultData.status || inputData.status || tool?.meta?.status || null,
|
||
};
|
||
return merged;
|
||
}
|
||
|
||
function effectiveObject(value) {
|
||
const parsed = parseMaybeJsonObject(value);
|
||
if (parsed && !Array.isArray(parsed)) return parsed;
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
||
return {};
|
||
}
|
||
|
||
function collabAgentStateEntries(data) {
|
||
const states = data?.agentsStates;
|
||
if (!states || typeof states !== 'object') return [];
|
||
return Object.entries(states).map(([id, value], index) => {
|
||
const state = value && typeof value === 'object' ? value : { status: value };
|
||
const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`);
|
||
const role = String(state.role || state.agent || state.agentType || '').trim();
|
||
let status = String(state.status || state.state || 'pending').trim() || 'pending';
|
||
if (state.closedAt && collabStateTone(status) !== 'closed') status = 'closed';
|
||
const detail = String(state.candidateResult || state.finalMessage || state.summary || state.message || state.lastMessage || state.step || state.description || '').trim();
|
||
return { id, label, role, status, detail };
|
||
});
|
||
}
|
||
|
||
function getCollabAgentIdsFromTool(tool) {
|
||
const data = normalizeCollabAgentData(tool);
|
||
const ids = new Set();
|
||
for (const entry of collabAgentStateEntries(data)) {
|
||
if (entry.id) ids.add(entry.id);
|
||
}
|
||
if (Array.isArray(data.receiverThreadIds)) {
|
||
data.receiverThreadIds.forEach((id) => {
|
||
if (id) ids.add(String(id));
|
||
});
|
||
}
|
||
const directIds = [
|
||
data.threadId,
|
||
data.thread_id,
|
||
data.agentId,
|
||
data.agent_id,
|
||
data.childThreadId,
|
||
data.child_thread_id,
|
||
data.childAgentId,
|
||
data.child_agent_id,
|
||
data.targetThreadId,
|
||
data.target_thread_id,
|
||
data.target,
|
||
];
|
||
directIds.forEach((id) => {
|
||
if (id) ids.add(String(id));
|
||
});
|
||
const directArrays = [
|
||
data.threadIds,
|
||
data.thread_ids,
|
||
data.childThreadIds,
|
||
data.child_thread_ids,
|
||
data.agentIds,
|
||
data.agent_ids,
|
||
data.targets,
|
||
];
|
||
directArrays.forEach((value) => {
|
||
if (!Array.isArray(value)) return;
|
||
value.forEach((id) => {
|
||
if (id) ids.add(String(id));
|
||
});
|
||
});
|
||
return Array.from(ids);
|
||
}
|
||
|
||
function getClosedCollabAgentIdsFromTool(tool) {
|
||
if (toolKind(tool) !== 'collab_agent_tool_call') return [];
|
||
const data = normalizeCollabAgentData(tool);
|
||
const ids = new Set();
|
||
const action = getCollabAgentAction(tool, data);
|
||
const allIds = getCollabAgentIdsFromTool(tool);
|
||
if (action === 'close_agent' || collabStateTone(data.status) === 'closed') {
|
||
allIds.forEach((id) => ids.add(id));
|
||
}
|
||
collabAgentStateEntries(data).forEach((entry) => {
|
||
if (entry.id && collabStateTone(entry.status) === 'closed') ids.add(entry.id);
|
||
});
|
||
return Array.from(ids);
|
||
}
|
||
|
||
function collectClosedCollabAgentIds(messages) {
|
||
const ids = new Set();
|
||
(Array.isArray(messages) ? messages : []).forEach((message) => {
|
||
(Array.isArray(message?.toolCalls) ? message.toolCalls : []).forEach((tool) => {
|
||
getClosedCollabAgentIdsFromTool(tool).forEach((id) => ids.add(id));
|
||
});
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
function rememberClosedCollabAgentIdsFromTool(tool) {
|
||
getClosedCollabAgentIdsFromTool(tool).forEach((id) => closedCollabAgentIds.add(id));
|
||
}
|
||
|
||
function isGenericCollabAgentLabel(label, id) {
|
||
const value = String(label || '').trim();
|
||
if (!value) return true;
|
||
if (/^子代理\s*\d+$/i.test(value)) return true;
|
||
return !!id && value === String(id);
|
||
}
|
||
|
||
function mergeCollabAgentTools(tools, options = {}) {
|
||
const list = Array.isArray(tools) ? tools.filter((tool) => toolKind(tool) === 'collab_agent_tool_call') : [];
|
||
if (list.length === 0) return null;
|
||
const states = {};
|
||
const receiverThreadIds = [];
|
||
const knownClosedIds = options.closedAgentIds instanceof Set ? options.closedAgentIds : closedCollabAgentIds;
|
||
const localClosedIds = new Set(knownClosedIds || []);
|
||
let toolName = '子代';
|
||
let prompt = '';
|
||
let status = '';
|
||
let done = false;
|
||
|
||
list.forEach((tool, toolIndex) => {
|
||
const data = normalizeCollabAgentData(tool);
|
||
const action = getCollabAgentAction(tool, data);
|
||
const isCloseAction = action === 'close_agent';
|
||
const displayAction = String(data.tool || tool.name || '').trim();
|
||
if (displayAction && !['wait_agent', 'close_agent'].includes(action)) toolName = displayAction;
|
||
if (!prompt && data.prompt) prompt = data.prompt;
|
||
if (isCloseAction) {
|
||
getCollabAgentIdsFromTool(tool).forEach((id) => localClosedIds.add(id));
|
||
}
|
||
const dataStatus = isCloseAction ? 'closed' : data.status;
|
||
if (dataStatus) status = dataStatus;
|
||
done = done || !!tool.done;
|
||
|
||
collabAgentStateEntries(data).forEach((entry) => {
|
||
if (!entry.id) return;
|
||
states[entry.id] = {
|
||
...(states[entry.id] || {}),
|
||
...entry,
|
||
status: isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status,
|
||
};
|
||
if (collabStateTone(states[entry.id].status) === 'closed') localClosedIds.add(entry.id);
|
||
});
|
||
|
||
getCollabAgentIdsFromTool(tool).forEach((id) => {
|
||
if (!receiverThreadIds.includes(id)) receiverThreadIds.push(id);
|
||
states[id] = {
|
||
...(states[id] || {}),
|
||
label: states[id]?.label || `子代理 ${receiverThreadIds.length}`,
|
||
status: isCloseAction || localClosedIds.has(id)
|
||
? 'closed'
|
||
: (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')),
|
||
};
|
||
});
|
||
|
||
if (receiverThreadIds.length === 0 && list.length === 1) {
|
||
const fallbackId = tool.id || `tool-${toolIndex + 1}`;
|
||
receiverThreadIds.push(fallbackId);
|
||
states[fallbackId] = {
|
||
label: '子代理',
|
||
status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')),
|
||
};
|
||
}
|
||
});
|
||
|
||
receiverThreadIds.forEach((id, index) => {
|
||
states[id] = {
|
||
...(states[id] || {}),
|
||
label: states[id]?.label || `子代理 ${index + 1}`,
|
||
status: localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'),
|
||
};
|
||
});
|
||
|
||
const allClosed = receiverThreadIds.length > 0
|
||
&& receiverThreadIds.every((id) => collabStateTone(states[id]?.status) === 'closed');
|
||
const mergedStatus = allClosed ? 'closed' : (status || (done ? 'completed' : 'running'));
|
||
|
||
return {
|
||
id: list[0].id || 'collab-agent-merged',
|
||
name: list[0].name || 'ccweb_mcp_child_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
done,
|
||
input: {
|
||
tool: toolName,
|
||
prompt,
|
||
status: mergedStatus,
|
||
receiverThreadIds,
|
||
agentsStates: states,
|
||
},
|
||
};
|
||
}
|
||
|
||
function collabStateTone(statusText) {
|
||
const normalized = String(statusText || '').toLowerCase();
|
||
if (!normalized) return 'pending';
|
||
if (/(closed|close)/.test(normalized)) return 'closed';
|
||
if (/(returned|done|completed|success|finished|idle)/.test(normalized)) return 'done';
|
||
if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error';
|
||
if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running';
|
||
return 'pending';
|
||
}
|
||
|
||
function collabStateLabel(statusText) {
|
||
const normalized = String(statusText || '').trim();
|
||
if (!normalized) return '等待中';
|
||
const lower = normalized.toLowerCase();
|
||
if (/(closed|close)/.test(lower)) return '已关闭';
|
||
if (/(returned)/.test(lower)) return '已返回';
|
||
if (/(done|completed|success|finished)/.test(lower)) return '已返回';
|
||
if (/(fail|error|rejected)/.test(lower)) return '失败';
|
||
if (/(cancel|aborted)/.test(lower)) return '已取消';
|
||
if (/(running|working|active|inprogress|in_progress|executing)/.test(lower)) return '进行中';
|
||
if (/(idle|pending|queued|waiting)/.test(lower)) return '等待中';
|
||
return normalized;
|
||
}
|
||
|
||
function createCollabAgentToolElement(tool) {
|
||
const data = normalizeCollabAgentData(tool);
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'tool-call-content collab-agent-content';
|
||
|
||
const stack = document.createElement('div');
|
||
stack.className = 'collab-agent-stack';
|
||
|
||
const stateEntries = collabAgentStateEntries(data);
|
||
const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0;
|
||
const agentCount = stateEntries.length;
|
||
const totalCount = agentCount || threadCount || 0;
|
||
const promptText = summarizePrompt(data.prompt);
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'collab-agent-header';
|
||
|
||
const titleWrap = document.createElement('div');
|
||
titleWrap.className = 'collab-agent-title-wrap';
|
||
|
||
const kicker = document.createElement('div');
|
||
kicker.className = 'collab-agent-kicker';
|
||
kicker.textContent = '子代';
|
||
titleWrap.appendChild(kicker);
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'collab-agent-title';
|
||
title.textContent = `${totalCount || 0} 个`;
|
||
titleWrap.appendChild(title);
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'collab-agent-meta';
|
||
meta.textContent = '';
|
||
if (promptText) meta.title = promptText;
|
||
if (promptText) titleWrap.appendChild(meta);
|
||
header.appendChild(titleWrap);
|
||
|
||
const headerActions = document.createElement('div');
|
||
headerActions.className = 'collab-agent-actions';
|
||
|
||
const statusChip = document.createElement('span');
|
||
const overallTone = collabStateTone(data.status || (tool.done ? 'completed' : 'running'));
|
||
statusChip.className = `collab-agent-overall-status ${overallTone}`;
|
||
statusChip.textContent = collabStateLabel(data.status || (tool.done ? 'completed' : 'running'));
|
||
headerActions.appendChild(statusChip);
|
||
|
||
header.appendChild(headerActions);
|
||
stack.appendChild(header);
|
||
|
||
if (stateEntries.length > 0) {
|
||
const list = document.createElement('div');
|
||
list.className = 'collab-agent-list';
|
||
stateEntries.forEach((entry, index) => {
|
||
const tone = collabStateTone(entry.status);
|
||
const item = document.createElement('div');
|
||
item.className = 'collab-agent-item';
|
||
item.title = [
|
||
entry.label || `子代理 ${index + 1}`,
|
||
entry.role ? `角色: ${entry.role}` : '',
|
||
entry.detail ? `结果: ${entry.detail}` : '',
|
||
entry.id ? `ID: ${entry.id}` : '',
|
||
].filter(Boolean).join('\n');
|
||
if (entry.id) {
|
||
item.setAttribute('role', 'button');
|
||
item.tabIndex = 0;
|
||
item.addEventListener('click', () => {
|
||
copyTextToClipboard(entry.id, '子代理线程 ID 已复制');
|
||
});
|
||
item.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
copyTextToClipboard(entry.id, '子代理线程 ID 已复制');
|
||
}
|
||
});
|
||
}
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'collab-agent-item-row';
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'collab-agent-item-label';
|
||
label.textContent = !isGenericCollabAgentLabel(entry.label, entry.id)
|
||
? entry.label
|
||
: `ID ${shortChildAgentId(entry.id || '')}`;
|
||
row.appendChild(label);
|
||
|
||
const chip = document.createElement('span');
|
||
chip.className = `collab-agent-item-status ${tone}`;
|
||
chip.textContent = collabStateLabel(entry.status);
|
||
row.appendChild(chip);
|
||
|
||
if (entry.id && tone !== 'closed') {
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.className = 'collab-agent-close-btn';
|
||
closeBtn.textContent = '关闭';
|
||
closeBtn.title = `关闭子代理\n${entry.id}`;
|
||
closeBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
closeBtn.disabled = true;
|
||
closeBtn.textContent = '关闭中';
|
||
send({
|
||
type: 'ccweb_mcp_child_agent_close',
|
||
sessionId: currentSessionId,
|
||
threadId: entry.id,
|
||
});
|
||
});
|
||
row.appendChild(closeBtn);
|
||
}
|
||
item.appendChild(row);
|
||
|
||
if (entry.id || entry.role) {
|
||
const footer = document.createElement('div');
|
||
footer.className = 'collab-agent-item-footer';
|
||
footer.textContent = entry.role || '';
|
||
if (!footer.textContent) footer.hidden = true;
|
||
item.appendChild(footer);
|
||
}
|
||
|
||
list.appendChild(item);
|
||
});
|
||
stack.appendChild(list);
|
||
}
|
||
|
||
if (stateEntries.length === 0 && Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) {
|
||
const threads = document.createElement('div');
|
||
threads.className = 'collab-agent-threads';
|
||
data.receiverThreadIds.forEach((threadId) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'collab-agent-thread-chip';
|
||
btn.textContent = `ID ${shortChildAgentId(threadId)}`;
|
||
btn.title = `复制子代理线程 ID\n${threadId}`;
|
||
btn.addEventListener('click', () => {
|
||
copyTextToClipboard(threadId, '子代理线程 ID 已复制');
|
||
});
|
||
threads.appendChild(btn);
|
||
});
|
||
stack.appendChild(threads);
|
||
}
|
||
|
||
if (!promptText && stateEntries.length === 0 && (!Array.isArray(data.receiverThreadIds) || data.receiverThreadIds.length === 0)) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'tool-call-empty';
|
||
empty.textContent = tool.done ? '子代理调用已结束,未返回结构化状态。' : '等待子代理状态…';
|
||
stack.appendChild(empty);
|
||
}
|
||
|
||
wrapper.appendChild(stack);
|
||
return wrapper;
|
||
}
|
||
|
||
function isGroupableToolCall(node) {
|
||
return !!(node?.classList?.contains('tool-call')
|
||
&& node.dataset.toolKind !== 'todo_list'
|
||
&& node.dataset.toolKind !== 'collab_agent_tool_call');
|
||
}
|
||
|
||
function rememberToolCallTarget(toolUseId, tool, element) {
|
||
if (!element) return;
|
||
const entry = activeToolCalls.get(toolUseId);
|
||
if (entry) {
|
||
entry.domElement = element;
|
||
}
|
||
if (toolKind(tool) === 'todo_list' && tool?.input?.id) {
|
||
activeTodoCallTargets.set(tool.input.id, element);
|
||
}
|
||
}
|
||
|
||
function getLatestAssistantToolScope() {
|
||
const streamEl = document.getElementById('streaming-msg');
|
||
if (streamEl) return streamEl;
|
||
const agentSelector = `.msg.assistant.agent-${normalizeAgent(currentAgent)}`;
|
||
const assistantMessages = messagesDiv.querySelectorAll(agentSelector);
|
||
if (assistantMessages.length > 0) {
|
||
return assistantMessages[assistantMessages.length - 1];
|
||
}
|
||
const fallbackMessages = messagesDiv.querySelectorAll('.msg.assistant');
|
||
return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null;
|
||
}
|
||
|
||
function buildMsgElement(m, messageIndex = null) {
|
||
const el = createMsgElement(m.role, m.content, m.attachments || [], m);
|
||
if (m.ccwebPrompt) {
|
||
const bubble = el.querySelector('.msg-bubble');
|
||
if (bubble) bubble.appendChild(createCcwebPromptElement(m.ccwebPrompt, { sessionId: currentSessionId }));
|
||
}
|
||
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
|
||
const bubble = el.querySelector('.msg-bubble');
|
||
const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble;
|
||
const FOLD_AT = 3;
|
||
let grouped = false;
|
||
const mergedCollabTool = mergeCollabAgentTools(m.toolCalls);
|
||
const renderToolCalls = [
|
||
...(mergedCollabTool ? [mergedCollabTool] : []),
|
||
...m.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'),
|
||
];
|
||
for (const tc of renderToolCalls) {
|
||
if (isEmptyReasoningTool(tc)) continue;
|
||
const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
|
||
|
||
// 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
|
||
const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
|
||
if (loose.length >= FOLD_AT) {
|
||
let group = toolMount.querySelector(':scope > .tool-group');
|
||
if (!group) {
|
||
group = document.createElement('details');
|
||
group.className = 'tool-group';
|
||
const gs = document.createElement('summary');
|
||
gs.className = 'tool-group-summary';
|
||
group.appendChild(gs);
|
||
const inner = document.createElement('div');
|
||
inner.className = 'tool-group-inner';
|
||
group.appendChild(inner);
|
||
toolMount.insertBefore(group, toolMount.firstChild);
|
||
grouped = true;
|
||
}
|
||
const inner = group.querySelector('.tool-group-inner');
|
||
loose.forEach(c => inner.appendChild(c));
|
||
_refreshGroupSummary(group);
|
||
}
|
||
toolMount.appendChild(details);
|
||
}
|
||
// 结束时若出现过父目录,收尾散落项
|
||
if (grouped) {
|
||
const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
|
||
if (loose.length > 0) {
|
||
const group = toolMount.querySelector(':scope > .tool-group');
|
||
if (group) {
|
||
const inner = group.querySelector('.tool-group-inner');
|
||
loose.forEach(c => inner.appendChild(c));
|
||
_refreshGroupSummary(group);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (Number.isFinite(messageIndex)) {
|
||
markSessionMessageElement(el, messageIndex);
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function renderMessages(messages, options = {}) {
|
||
renderEpoch++;
|
||
const epoch = renderEpoch;
|
||
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
|
||
closedCollabAgentIds = collectClosedCollabAgentIds(messages);
|
||
messagesDiv.innerHTML = '';
|
||
clearUserMessageIndex();
|
||
if (messages.length === 0) {
|
||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||
updateUserOutlinePanel();
|
||
renderPendingNotes({ scroll: false });
|
||
scrollToBottom();
|
||
return;
|
||
}
|
||
if (options.immediate) {
|
||
const frag = document.createDocumentFragment();
|
||
messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
|
||
messagesDiv.appendChild(frag);
|
||
updateUserOutlinePanel();
|
||
renderPendingNotes({ scroll: false });
|
||
scrollToBottom();
|
||
return;
|
||
}
|
||
// Batch render: last 10 first, then next 20, then the rest
|
||
const batches = [];
|
||
const len = messages.length;
|
||
if (len <= 10) {
|
||
batches.push([0, len]);
|
||
} else if (len <= 30) {
|
||
batches.push([len - 10, len]);
|
||
batches.push([0, len - 10]);
|
||
} else {
|
||
batches.push([len - 10, len]);
|
||
batches.push([len - 30, len - 10]);
|
||
batches.push([0, len - 30]);
|
||
}
|
||
|
||
// Render first batch immediately
|
||
const frag0 = document.createDocumentFragment();
|
||
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i));
|
||
messagesDiv.appendChild(frag0);
|
||
updateUserOutlinePanel();
|
||
renderPendingNotes({ scroll: false });
|
||
scrollToBottom();
|
||
|
||
// Render remaining batches asynchronously, prepending each
|
||
// Use scrollHeight delta to keep current view position stable after prepend
|
||
let delay = 0;
|
||
for (let b = 1; b < batches.length; b++) {
|
||
const [start, end] = batches[b];
|
||
delay += 16;
|
||
setTimeout(() => {
|
||
if (renderEpoch !== epoch) return; // session switched, abort stale render
|
||
const prevHeight = messagesDiv.scrollHeight;
|
||
const prevScrollTop = messagesDiv.scrollTop;
|
||
const frag = document.createDocumentFragment();
|
||
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i));
|
||
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
|
||
updateUserOutlinePanel();
|
||
// Compensate scrollTop so visible area stays unchanged
|
||
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
|
||
updateScrollbar();
|
||
}, delay);
|
||
}
|
||
}
|
||
|
||
function prependHistoryMessages(messages, options = {}) {
|
||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
|
||
collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id));
|
||
const preserveScroll = options.preserveScroll !== false;
|
||
const skipScrollbar = options.skipScrollbar === true;
|
||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||
if (welcome) welcome.remove();
|
||
const frag = document.createDocumentFragment();
|
||
messages.forEach((m, index) => frag.appendChild(buildMsgElement(m, baseIndex + index)));
|
||
if (!preserveScroll) {
|
||
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
|
||
updateUserOutlinePanel();
|
||
if (!skipScrollbar) updateScrollbar();
|
||
return;
|
||
}
|
||
const prevHeight = messagesDiv.scrollHeight;
|
||
const prevScrollTop = messagesDiv.scrollTop;
|
||
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
|
||
updateUserOutlinePanel();
|
||
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
|
||
if (!skipScrollbar) updateScrollbar();
|
||
}
|
||
|
||
function normalizeAskUserInput(input) {
|
||
if (input === null || input === undefined) return null;
|
||
if (typeof input === 'string') {
|
||
const trimmed = input.trim();
|
||
if (!trimmed) return null;
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
return input;
|
||
}
|
||
|
||
function extractAskUserQuestions(input) {
|
||
const parsed = normalizeAskUserInput(input);
|
||
if (!parsed || !Array.isArray(parsed.questions)) return [];
|
||
return parsed.questions;
|
||
}
|
||
|
||
function appendAskOptionToInput(question, option) {
|
||
const header = (question?.header || '').trim() || '问题';
|
||
const line = `【${header}】${option?.label || ''}`;
|
||
const current = msgInput.value.trim();
|
||
msgInput.value = current ? `${current}\n${line}` : line;
|
||
autoResize();
|
||
msgInput.focus();
|
||
}
|
||
|
||
function createAskUserQuestionView(questions) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'ask-user-question';
|
||
|
||
questions.forEach((q, idx) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'ask-question-card';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'ask-question-header';
|
||
header.textContent = `${idx + 1}. ${q.header || '问题'}`;
|
||
card.appendChild(header);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'ask-question-text';
|
||
body.textContent = q.question || '';
|
||
card.appendChild(body);
|
||
|
||
if (Array.isArray(q.options) && q.options.length > 0) {
|
||
const hasDesc = q.options.some(o => o.description);
|
||
|
||
// 左右分栏容器
|
||
const layout = document.createElement('div');
|
||
layout.className = 'ask-options-layout' + (hasDesc ? ' has-preview' : '');
|
||
|
||
const opts = document.createElement('div');
|
||
opts.className = 'ask-question-options';
|
||
|
||
// 右侧预览区(仅在有 description 时创建)
|
||
const preview = hasDesc ? document.createElement('div') : null;
|
||
if (preview) {
|
||
preview.className = 'ask-option-preview';
|
||
// 默认显示第一项
|
||
preview.textContent = q.options[0].description || '';
|
||
}
|
||
|
||
// 当前选中项(移动端 tap-to-preview 状态)
|
||
let selectedOpt = null;
|
||
let selectedBtn = null;
|
||
|
||
q.options.forEach((opt, i) => {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'ask-option-item';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'ask-option-label';
|
||
title.textContent = `${i + 1}. ${opt.label || ''}`;
|
||
item.appendChild(title);
|
||
|
||
// 桌面:hover 切换预览
|
||
if (preview) {
|
||
item.addEventListener('mouseenter', () => {
|
||
preview.textContent = opt.description || '';
|
||
});
|
||
}
|
||
|
||
item.addEventListener('click', (e) => {
|
||
const isTouch = item.dataset.touchActivated === '1';
|
||
item.dataset.touchActivated = '';
|
||
|
||
if (isTouch) {
|
||
// 移动端:第一次 tap = 选中预览,不发送
|
||
if (selectedBtn !== item) {
|
||
if (selectedBtn) selectedBtn.classList.remove('ask-option-selected');
|
||
selectedBtn = item;
|
||
selectedOpt = opt;
|
||
item.classList.add('ask-option-selected');
|
||
if (preview) preview.textContent = opt.description || '';
|
||
return;
|
||
}
|
||
// 第二次 tap 同一项 = 发送
|
||
}
|
||
|
||
// 桌面直接发送
|
||
appendAskOptionToInput(q, opt);
|
||
});
|
||
|
||
item.addEventListener('touchstart', () => {
|
||
item.dataset.touchActivated = '1';
|
||
}, { passive: true });
|
||
|
||
opts.appendChild(item);
|
||
});
|
||
|
||
layout.appendChild(opts);
|
||
if (preview) {
|
||
layout.appendChild(preview);
|
||
// 预览区最小高度 = 左侧选项列表总高度(渲染后同步)
|
||
requestAnimationFrame(() => {
|
||
preview.style.minHeight = opts.offsetHeight + 'px';
|
||
});
|
||
}
|
||
|
||
// 移动端确认按钮
|
||
if (hasDesc) {
|
||
const confirmBtn = document.createElement('button');
|
||
confirmBtn.type = 'button';
|
||
confirmBtn.className = 'ask-confirm-btn';
|
||
confirmBtn.textContent = '确认选择';
|
||
confirmBtn.addEventListener('click', () => {
|
||
if (selectedOpt) {
|
||
appendAskOptionToInput(q, selectedOpt);
|
||
} else if (q.options.length > 0) {
|
||
appendAskOptionToInput(q, q.options[0]);
|
||
}
|
||
});
|
||
layout.appendChild(confirmBtn);
|
||
}
|
||
|
||
card.appendChild(layout);
|
||
}
|
||
|
||
wrapper.appendChild(card);
|
||
});
|
||
|
||
return wrapper;
|
||
}
|
||
|
||
function buildToolContentElement(name, input) {
|
||
const tool = typeof name === 'object' && name !== null ? name : { name, input };
|
||
const effectiveName = tool.name || name;
|
||
const effectiveInput = tool.input !== undefined ? tool.input : input;
|
||
const effectiveResult = tool.result;
|
||
const kind = toolKind(tool);
|
||
|
||
if (kind === 'todo_list') {
|
||
let todoData = effectiveInput;
|
||
// 如果有 result 且是字符串,尝试解析
|
||
if (effectiveResult && typeof effectiveResult === 'string') {
|
||
try {
|
||
todoData = JSON.parse(effectiveResult);
|
||
} catch (e) {
|
||
}
|
||
} else {
|
||
}
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'tool-call-content todo-list-content';
|
||
wrapper.appendChild(createTodoListElement(todoData && typeof todoData === 'object' ? todoData : { items: [] }));
|
||
return wrapper;
|
||
}
|
||
|
||
if (kind === 'collab_agent_tool_call') {
|
||
return createCollabAgentToolElement(tool);
|
||
}
|
||
|
||
if (effectiveName === 'AskUserQuestion') {
|
||
const questions = extractAskUserQuestions(effectiveInput);
|
||
if (questions.length > 0) {
|
||
return createAskUserQuestionView(questions);
|
||
}
|
||
}
|
||
|
||
if (kind === 'command_execution') {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'tool-call-content command';
|
||
const stack = document.createElement('div');
|
||
stack.className = 'tool-call-structured';
|
||
const commandText = effectiveInput?.command || tool?.meta?.subtitle || '';
|
||
if (commandText) stack.appendChild(buildStructuredToolSection('Command', commandText));
|
||
if (effectiveResult) {
|
||
stack.appendChild(buildStructuredToolSection('Output', stringifyToolValue(effectiveResult)));
|
||
} else if (!tool.done) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'tool-call-empty';
|
||
empty.textContent = '等待命令输出…';
|
||
stack.appendChild(empty);
|
||
}
|
||
wrapper.appendChild(stack);
|
||
return wrapper;
|
||
}
|
||
|
||
if (kind === 'reasoning') {
|
||
const content = document.createElement('div');
|
||
content.className = 'tool-call-content reasoning';
|
||
const text = stringifyToolValue(effectiveResult || effectiveInput);
|
||
content.innerHTML = text ? renderMarkdown(text) : '<div class="tool-call-empty">暂无推理内容</div>';
|
||
return content;
|
||
}
|
||
|
||
if (kind === 'file_change' || kind === 'mcp_tool_call') {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = `tool-call-content ${kind === 'file_change' ? 'file-change' : ''}`.trim();
|
||
const stack = document.createElement('div');
|
||
stack.className = 'tool-call-structured';
|
||
if (tool?.meta?.subtitle) {
|
||
stack.appendChild(buildStructuredToolSection(kind === 'file_change' ? 'Target' : 'Tool', tool.meta.subtitle));
|
||
}
|
||
const payloadText = stringifyToolValue(effectiveResult || effectiveInput);
|
||
if (payloadText) {
|
||
stack.appendChild(buildStructuredToolSection('Payload', payloadText));
|
||
}
|
||
wrapper.appendChild(stack);
|
||
return wrapper;
|
||
}
|
||
|
||
const inputStr = stringifyToolValue(effectiveResult || effectiveInput);
|
||
const content = document.createElement('div');
|
||
content.className = 'tool-call-content';
|
||
content.textContent = inputStr;
|
||
return content;
|
||
}
|
||
|
||
function createToolCallElement(toolUseId, tool, done) {
|
||
const kind = toolKind(tool);
|
||
if (kind === 'collab_agent_tool_call') {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'tool-call ccweb-mcp-child-agent-tool-call collab-agent-inline';
|
||
wrapper.id = `tool-node-${++toolDomSeq}`;
|
||
wrapper.dataset.toolUseId = toolUseId ? String(toolUseId) : '';
|
||
wrapper.dataset.toolName = tool.name || '';
|
||
wrapper.dataset.toolKind = kind;
|
||
wrapper.dataset.childIds = getCollabAgentIdsFromTool(tool).join(',');
|
||
wrapper.appendChild(buildToolContentElement({ ...tool, done }));
|
||
return wrapper;
|
||
}
|
||
|
||
const details = document.createElement('details');
|
||
details.className = 'tool-call';
|
||
details.id = `tool-node-${++toolDomSeq}`;
|
||
details.dataset.toolUseId = toolUseId ? String(toolUseId) : '';
|
||
details.dataset.toolName = tool.name || '';
|
||
if (toolKind(tool)) {
|
||
details.dataset.toolKind = toolKind(tool);
|
||
details.classList.add(`codex-${toolKind(tool).replace(/_/g, '-')}`);
|
||
}
|
||
// Default expansion policy:
|
||
// - Always open AskUserQuestion (it is an actionable UI).
|
||
// - For non-Codex sessions, auto-open in-flight command execution so users can watch output.
|
||
// - For Codex sessions, keep everything collapsed by default (less noise), including in-flight commands.
|
||
const agent = normalizeAgent(currentAgent);
|
||
if (tool.name === 'AskUserQuestion') {
|
||
details.open = true;
|
||
} else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') {
|
||
details.open = true;
|
||
}
|
||
|
||
const summary = document.createElement('summary');
|
||
applyToolSummary(summary, tool, done);
|
||
details.appendChild(summary);
|
||
details.appendChild(buildToolContentElement({ ...tool, done }));
|
||
return details;
|
||
}
|
||
|
||
function upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done) {
|
||
if (!toolsDiv || !tool) return null;
|
||
const existing = toolsDiv.querySelector(':scope > .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]')
|
||
|| toolsDiv.querySelector(':scope > .tool-group .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]');
|
||
const nextTool = { ...tool, id: toolUseId, done };
|
||
rememberClosedCollabAgentIdsFromTool(nextTool);
|
||
const map = existing?.__collabTools instanceof Map ? existing.__collabTools : new Map();
|
||
map.set(toolUseId || nextTool.id || `collab-${map.size + 1}`, nextTool);
|
||
const merged = mergeCollabAgentTools(Array.from(map.values()));
|
||
if (!merged) return existing;
|
||
|
||
if (existing) {
|
||
existing.__collabTools = map;
|
||
existing.dataset.toolUseId = merged.id || existing.dataset.toolUseId || '';
|
||
existing.dataset.childIds = getCollabAgentIdsFromTool(merged).join(',');
|
||
existing.replaceChildren(buildToolContentElement(merged));
|
||
removeDuplicateCollabAgentNodes(toolsDiv);
|
||
return existing;
|
||
}
|
||
|
||
const el = createToolCallElement(merged.id, merged, !!merged.done);
|
||
el.dataset.collabMerged = 'true';
|
||
el.dataset.childIds = getCollabAgentIdsFromTool(merged).join(',');
|
||
el.__collabTools = map;
|
||
toolsDiv.appendChild(el);
|
||
removeDuplicateCollabAgentNodes(toolsDiv);
|
||
return el;
|
||
}
|
||
|
||
function removeDuplicateCollabAgentNodes(scope) {
|
||
if (!scope) return;
|
||
const seen = new Set();
|
||
const nodes = Array.from(scope.querySelectorAll('.ccweb-mcp-child-agent-tool-call'));
|
||
nodes.forEach((node) => {
|
||
const ids = String(node.dataset.childIds || '').split(',').filter(Boolean);
|
||
const duplicate = ids.length > 0 && ids.some((id) => seen.has(id));
|
||
ids.forEach((id) => seen.add(id));
|
||
if (duplicate) node.remove();
|
||
});
|
||
}
|
||
|
||
function appendToolCall(toolUseId, name, input, done, kind = null, meta = null, result = undefined) {
|
||
const streamEl = document.getElementById('streaming-msg');
|
||
if (!streamEl) return;
|
||
const bubble = streamEl.querySelector('.msg-bubble');
|
||
if (!bubble) return;
|
||
const shouldFollow = isNearBottom();
|
||
let toolsDiv = bubble.querySelector('.msg-tools');
|
||
if (!toolsDiv) { toolsDiv = bubble; }
|
||
|
||
const tool = { id: toolUseId, name, input, kind, meta, done };
|
||
if (result !== undefined) tool.result = result;
|
||
if (isEmptyReasoningTool(tool)) return;
|
||
if (toolKind(tool) === 'collab_agent_tool_call') {
|
||
const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done);
|
||
if (el) rememberToolCallTarget(toolUseId, tool, el);
|
||
followOutputIfNeeded(shouldFollow);
|
||
return;
|
||
}
|
||
|
||
// 如果是 todo_list,检查是否已存在相同 id 的 todo_list
|
||
if (kind === 'todo_list' && input?.id) {
|
||
const existingTodo = findTodoToolCallByTodoId(toolsDiv, input.id);
|
||
if (existingTodo) {
|
||
const details = createToolCallElement(toolUseId, tool, done);
|
||
existingTodo.replaceWith(details);
|
||
rememberToolCallTarget(toolUseId, tool, details);
|
||
followOutputIfNeeded(shouldFollow);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const details = createToolCallElement(toolUseId, tool, done);
|
||
|
||
// 折叠策略:只维护唯一一个 .tool-group 父节点
|
||
// 散落的 .tool-call 直接子节点达到3个时,将它们全部移入父节点;之后继续散落,再达3个再移入
|
||
const FOLD_AT = 3;
|
||
const looseBefore = Array.from(toolsDiv.children).filter(isGroupableToolCall);
|
||
if (looseBefore.length >= FOLD_AT) {
|
||
// 确保存在唯一的 .tool-group
|
||
let group = toolsDiv.querySelector(':scope > .tool-group');
|
||
if (!group) {
|
||
group = document.createElement('details');
|
||
group.className = 'tool-group';
|
||
const gs = document.createElement('summary');
|
||
gs.className = 'tool-group-summary';
|
||
group.appendChild(gs);
|
||
const inner = document.createElement('div');
|
||
inner.className = 'tool-group-inner';
|
||
group.appendChild(inner);
|
||
toolsDiv.insertBefore(group, toolsDiv.firstChild);
|
||
hasGrouped = true;
|
||
}
|
||
const inner = group.querySelector('.tool-group-inner');
|
||
looseBefore.forEach(c => inner.appendChild(c));
|
||
_refreshGroupSummary(group);
|
||
}
|
||
toolsDiv.appendChild(details);
|
||
rememberToolCallTarget(toolUseId, tool, details);
|
||
followOutputIfNeeded(shouldFollow);
|
||
}
|
||
|
||
function _refreshGroupSummary(group) {
|
||
const inner = group.querySelector('.tool-group-inner');
|
||
const count = inner ? inner.childElementCount : 0;
|
||
const summary = group.querySelector('.tool-group-summary');
|
||
if (summary) summary.textContent = `展开 ${count} 个工具调用`;
|
||
}
|
||
|
||
function findLatestToolCallElement(root, matcher) {
|
||
if (!root || typeof matcher !== 'function') return null;
|
||
const allTools = root.querySelectorAll('.tool-call');
|
||
for (let i = allTools.length - 1; i >= 0; i--) {
|
||
const el = allTools[i];
|
||
if (matcher(el)) return el;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findTodoToolCallByTodoId(root, todoId) {
|
||
if (!todoId) return null;
|
||
return findLatestToolCallElement(root, (el) => {
|
||
if (el.dataset.toolKind !== 'todo_list') return false;
|
||
const currentTodoId = el.querySelector('.todo-list-container')?.dataset.todoId;
|
||
return currentTodoId === todoId;
|
||
});
|
||
}
|
||
|
||
function updateToolCall(toolUseId, result, done = true) {
|
||
const tool = activeToolCalls.get(toolUseId) || null;
|
||
const toolUseIdText = toolUseId ? String(toolUseId) : '';
|
||
const scope = getLatestAssistantToolScope();
|
||
if (toolKind(tool) === 'collab_agent_tool_call') {
|
||
const toolsDiv = scope?.querySelector?.('.msg-tools') || scope?.querySelector?.('.msg-bubble') || scope;
|
||
const nextTool = { ...tool, result, done };
|
||
const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, nextTool, done);
|
||
if (el) rememberToolCallTarget(toolUseId, nextTool, el);
|
||
return;
|
||
}
|
||
let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null;
|
||
|
||
if (!el) {
|
||
if (tool?.kind === 'todo_list' && tool?.input?.id) {
|
||
const markedTodo = activeTodoCallTargets.get(tool.input.id);
|
||
if (markedTodo && markedTodo.isConnected) {
|
||
el = markedTodo;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!el) {
|
||
el = findLatestToolCallElement(scope, (candidate) => candidate.dataset.toolUseId === toolUseIdText);
|
||
}
|
||
|
||
if (!el) {
|
||
el = findLatestToolCallElement(messagesDiv, (candidate) => candidate.dataset.toolUseId === toolUseIdText);
|
||
}
|
||
|
||
if (!el && tool?.kind === 'todo_list' && tool?.input?.id) {
|
||
el = findTodoToolCallByTodoId(scope, tool.input.id);
|
||
}
|
||
|
||
if (!el) {
|
||
return;
|
||
}
|
||
|
||
const nextTool = tool || {
|
||
id: toolUseId,
|
||
name: el.dataset.toolName || '',
|
||
kind: el.dataset.toolKind || null,
|
||
done,
|
||
};
|
||
nextTool.done = done;
|
||
if (result !== undefined) nextTool.result = result;
|
||
if (isEmptyReasoningTool(nextTool)) {
|
||
activeToolCalls.delete(toolUseId);
|
||
el.remove();
|
||
return;
|
||
}
|
||
rememberToolCallTarget(toolUseId, nextTool, el);
|
||
const summary = el.querySelector('summary');
|
||
if (summary) applyToolSummary(summary, nextTool, done);
|
||
if (nextTool.name === 'AskUserQuestion') return;
|
||
const nextContent = buildToolContentElement(nextTool);
|
||
const content = Array.from(el.children).find((child) => child.tagName !== 'SUMMARY') || null;
|
||
if (content) {
|
||
content.replaceWith(nextContent);
|
||
} else {
|
||
el.appendChild(nextContent);
|
||
}
|
||
}
|
||
|
||
function applyCcwebMcpChildAgentUpdate(msg) {
|
||
const tool = msg?.tool;
|
||
const toolUseId = msg?.toolUseId || tool?.id;
|
||
if (!toolUseId || !tool) return;
|
||
const isCurrentSessionUpdate = msg.sessionId === currentSessionId;
|
||
if (isCurrentSessionUpdate) {
|
||
if (msg?.child?.threadId && collabStateTone(msg.child.status) === 'closed') {
|
||
closedCollabAgentIds.add(String(msg.child.threadId));
|
||
}
|
||
rememberClosedCollabAgentIdsFromTool(tool);
|
||
}
|
||
|
||
updateCachedSession(msg.sessionId, (snapshot) => {
|
||
const messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
|
||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||
const calls = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : [];
|
||
const target = calls.find((item) => item.id === toolUseId);
|
||
if (!target) continue;
|
||
target.name = tool.name || target.name;
|
||
target.kind = tool.kind || target.kind;
|
||
target.input = tool.input !== undefined ? tool.input : target.input;
|
||
target.result = tool.result !== undefined ? tool.result : target.result;
|
||
target.meta = tool.meta || target.meta || null;
|
||
target.done = tool.done !== undefined ? !!tool.done : target.done;
|
||
snapshot.updated = new Date().toISOString();
|
||
break;
|
||
}
|
||
});
|
||
|
||
if (!isCurrentSessionUpdate) return;
|
||
activeToolCalls.set(toolUseId, {
|
||
id: toolUseId,
|
||
name: tool.name,
|
||
input: tool.input,
|
||
result: tool.result,
|
||
kind: tool.kind || null,
|
||
meta: tool.meta || null,
|
||
done: !!tool.done,
|
||
});
|
||
updateToolCall(toolUseId, tool.result, !!tool.done);
|
||
}
|
||
|
||
function getDeleteConfirmMessage(agent) {
|
||
const normalized = normalizeAgent(agent);
|
||
if (normalized === 'codex') {
|
||
return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?';
|
||
}
|
||
if (normalized === 'codexapp') {
|
||
return '删除本会话只会删除 cc-web 中的 Codex App 会话记录,不会清理本地 Codex App 线程历史。确认删除?';
|
||
}
|
||
return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?';
|
||
}
|
||
|
||
function showSimpleConfirm(options = {}) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay';
|
||
overlay.style.zIndex = '10002';
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'settings-panel';
|
||
const title = options.title ? `<div style="font-size:1em;font-weight:700;color:var(--text-primary);margin-bottom:10px">${escapeHtml(options.title)}</div>` : '';
|
||
const confirmText = options.confirmText || '确认';
|
||
const cancelText = options.cancelText || '取消';
|
||
|
||
box.innerHTML = `
|
||
${title}
|
||
<div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7;word-break:break-word;white-space:pre-line">${escapeHtml(options.message || '')}</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<button id="simple-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:var(--accent-ink, #fff);font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">${escapeHtml(confirmText)}</button>
|
||
<button id="simple-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">${escapeHtml(cancelText)}</button>
|
||
</div>
|
||
`;
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
|
||
const close = () => {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
};
|
||
box.querySelector('#simple-confirm-ok').addEventListener('click', () => {
|
||
close();
|
||
if (typeof options.onConfirm === 'function') options.onConfirm();
|
||
});
|
||
box.querySelector('#simple-confirm-cancel').addEventListener('click', () => {
|
||
close();
|
||
if (typeof options.onCancel === 'function') options.onCancel();
|
||
});
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) {
|
||
close();
|
||
if (typeof options.onCancel === 'function') options.onCancel();
|
||
}
|
||
});
|
||
}
|
||
|
||
function showDeleteConfirm(agent, onConfirm) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay';
|
||
overlay.style.zIndex = '10002';
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'settings-panel';
|
||
box.innerHTML = `
|
||
<div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7">${escapeHtml(getDeleteConfirmMessage(agent))}</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<button id="del-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:var(--accent-ink, #fff);font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">确认删除</button>
|
||
<button id="del-confirm-skip" style="width:100%;padding:9px;border:1px solid var(--border-color);border-radius:10px;background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.85em;cursor:pointer;font-family:inherit">确认且不再提示</button>
|
||
<button id="del-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">取消</button>
|
||
</div>
|
||
`;
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
|
||
const close = () => document.body.removeChild(overlay);
|
||
box.querySelector('#del-confirm-ok').addEventListener('click', () => { close(); onConfirm(); });
|
||
box.querySelector('#del-confirm-skip').addEventListener('click', () => {
|
||
skipDeleteConfirm = true;
|
||
localStorage.setItem('cc-web-skip-delete-confirm', '1');
|
||
close();
|
||
onConfirm();
|
||
});
|
||
box.querySelector('#del-confirm-cancel').addEventListener('click', close);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
}
|
||
|
||
function appendSystemMessage(message, options = {}) {
|
||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||
if (welcome) welcome.remove();
|
||
messagesDiv.appendChild(createMsgElement('system', message, [], options));
|
||
if (options.preserveScroll !== true) {
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
function appendError(message, options = {}) {
|
||
appendSystemMessage(`⚠ ${message}`, {
|
||
tone: 'danger',
|
||
transient: options.transient !== false,
|
||
autoDismissMs: options.autoDismissMs || 7000,
|
||
preserveScroll: options.preserveScroll !== false,
|
||
});
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
requestAnimationFrame(() => {
|
||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||
updateScrollbar();
|
||
});
|
||
}
|
||
|
||
function isNearBottom(threshold = 96) {
|
||
const distance = messagesDiv.scrollHeight - messagesDiv.clientHeight - messagesDiv.scrollTop;
|
||
return distance <= threshold;
|
||
}
|
||
|
||
function scrollToBottomIfNear(threshold = 96) {
|
||
if (!isNearBottom(threshold)) return false;
|
||
scrollToBottom();
|
||
return true;
|
||
}
|
||
|
||
function followOutputIfNeeded(shouldFollow) {
|
||
if (shouldFollow) {
|
||
scrollToBottom();
|
||
} else {
|
||
updateScrollbar();
|
||
}
|
||
}
|
||
|
||
// --- Custom Scrollbar ---
|
||
const scrollbarEl = document.getElementById('custom-scrollbar');
|
||
const thumbEl = document.getElementById('custom-scrollbar-thumb');
|
||
|
||
function updateScrollbar() {
|
||
if (!scrollbarEl || !thumbEl) return;
|
||
const { scrollTop, scrollHeight, clientHeight } = messagesDiv;
|
||
if (scrollHeight <= clientHeight) {
|
||
thumbEl.style.display = 'none';
|
||
return;
|
||
}
|
||
thumbEl.style.display = '';
|
||
const trackH = scrollbarEl.clientHeight;
|
||
const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
|
||
const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH);
|
||
thumbEl.style.height = thumbH + 'px';
|
||
thumbEl.style.top = thumbTop + 'px';
|
||
}
|
||
|
||
messagesDiv.addEventListener('scroll', () => {
|
||
updateScrollbar();
|
||
// 移动端:滚动时短暂显示滑块,停止后淡出
|
||
scrollbarEl.classList.add('scrolling');
|
||
clearTimeout(scrollbarEl._hideTimer);
|
||
scrollbarEl._hideTimer = setTimeout(() => {
|
||
if (!isDragging) scrollbarEl.classList.remove('scrolling');
|
||
}, 1200);
|
||
}, { passive: true });
|
||
new ResizeObserver(updateScrollbar).observe(messagesDiv);
|
||
|
||
// Drag logic
|
||
let dragStartY = 0, dragStartScrollTop = 0, isDragging = false;
|
||
|
||
function onDragStart(e) {
|
||
isDragging = true;
|
||
dragStartY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
|
||
dragStartScrollTop = messagesDiv.scrollTop;
|
||
thumbEl.classList.add('dragging');
|
||
scrollbarEl.classList.add('active');
|
||
e.preventDefault();
|
||
}
|
||
|
||
function onDragMove(e) {
|
||
if (!isDragging) return;
|
||
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
|
||
const dy = clientY - dragStartY;
|
||
const { scrollHeight, clientHeight } = messagesDiv;
|
||
const trackH = scrollbarEl.clientHeight;
|
||
const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
|
||
const ratio = (scrollHeight - clientHeight) / (trackH - thumbH);
|
||
messagesDiv.scrollTop = dragStartScrollTop + dy * ratio;
|
||
e.preventDefault();
|
||
}
|
||
|
||
function onDragEnd() {
|
||
if (!isDragging) return;
|
||
isDragging = false;
|
||
thumbEl.classList.remove('dragging');
|
||
scrollbarEl.classList.remove('active');
|
||
}
|
||
|
||
thumbEl.addEventListener('mousedown', onDragStart);
|
||
thumbEl.addEventListener('touchstart', onDragStart, { passive: false });
|
||
document.addEventListener('mousemove', onDragMove);
|
||
document.addEventListener('touchmove', onDragMove, { passive: false });
|
||
document.addEventListener('mouseup', onDragEnd);
|
||
document.addEventListener('touchend', onDragEnd);
|
||
|
||
updateScrollbar();
|
||
|
||
|
||
function renderSessionList() {
|
||
sessionList.innerHTML = '';
|
||
syncSessionSearchUi();
|
||
const allVisibleSessions = getVisibleSessions();
|
||
const normalizedSearchQuery = normalizeSessionSearchQuery(sessionSearchQuery);
|
||
const isSearchingSessions = !!normalizedSearchQuery;
|
||
const visibleSessions = isSearchingSessions
|
||
? allVisibleSessions.filter((session) => sessionMatchesSearch(session, normalizedSearchQuery))
|
||
: allVisibleSessions;
|
||
if (allVisibleSessions.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'session-list-empty';
|
||
empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话,点击“新会话”开始。`;
|
||
sessionList.appendChild(empty);
|
||
return;
|
||
}
|
||
if (visibleSessions.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'session-list-empty';
|
||
empty.textContent = '没有匹配的会话或项目。';
|
||
sessionList.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
|
||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
|
||
if (pinnedSessions.length > 0) {
|
||
const pinnedGroupEl = document.createElement('section');
|
||
pinnedGroupEl.className = 'session-project-group session-pinned-group';
|
||
const pinnedHeader = document.createElement('div');
|
||
pinnedHeader.className = 'session-project-header session-pinned-header';
|
||
pinnedHeader.innerHTML = `
|
||
<span class="session-project-name">置顶</span>
|
||
<span class="session-project-header-actions">
|
||
<span class="session-project-count">${pinnedSessions.length}</span>
|
||
</span>
|
||
`;
|
||
pinnedGroupEl.appendChild(pinnedHeader);
|
||
for (const session of pinnedSessions) {
|
||
pinnedGroupEl.appendChild(createSessionListItem(session));
|
||
}
|
||
sessionList.appendChild(pinnedGroupEl);
|
||
}
|
||
|
||
projectGroups.forEach((group, groupIndex) => {
|
||
const groupKey = getProjectCollapseKey(group);
|
||
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
|
||
const { visibleSessions: visibleGroupSessions, hiddenSessions: hiddenGroupSessions } = isSearchingSessions
|
||
? { visibleSessions: group.sessions, hiddenSessions: [] }
|
||
: splitCollapsedSessions(group.sessions, oldSessionCollapseKey);
|
||
const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey);
|
||
const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
|
||
const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
|
||
const hasRunningSession = group.sessions.some((session) => session.isRunning);
|
||
const hasWaitingSession = group.sessions.some((session) => session.waitingOnChildren);
|
||
const groupBodyId = `session-project-body-${groupIndex}`;
|
||
const groupEl = document.createElement('section');
|
||
groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}${hasWaitingSession ? ' has-waiting-session' : ''}`;
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'session-project-header';
|
||
header.title = group.cwd || group.name;
|
||
header.innerHTML = `
|
||
<button class="session-project-toggle" type="button" aria-expanded="${isCollapsed ? 'false' : 'true'}" aria-controls="${groupBodyId}" title="${isCollapsed ? '展开项目' : '折叠项目'}">
|
||
<span class="session-project-chevron" aria-hidden="true">${isCollapsed ? '▸' : '▾'}</span>
|
||
<span class="session-project-name">${escapeHtml(group.name)}</span>
|
||
</button>
|
||
<span class="session-project-header-actions">
|
||
<span class="session-project-count">${group.sessions.length}</span>
|
||
<button class="session-project-create" type="button" title="在此项目新建会话" aria-label="在此项目新建会话">+</button>
|
||
</span>
|
||
`;
|
||
groupEl.appendChild(header);
|
||
|
||
const groupBody = document.createElement('div');
|
||
groupBody.id = groupBodyId;
|
||
groupBody.className = 'session-project-sessions';
|
||
groupBody.hidden = isCollapsed;
|
||
for (const s of visibleGroupSessions) {
|
||
groupBody.appendChild(createSessionListItem(s));
|
||
}
|
||
if (hiddenGroupSessions.length > 0) {
|
||
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupSessions.length, oldSessionCollapseKey, group.name));
|
||
}
|
||
groupEl.appendChild(groupBody);
|
||
|
||
header.querySelector('.session-project-toggle').addEventListener('click', () => {
|
||
setProjectCollapsed(groupKey, !isCollapsed);
|
||
});
|
||
|
||
header.querySelector('.session-project-create').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
quickCreateProjectSession(group.cwd || '', { agent: currentAgent, mode: currentMode });
|
||
});
|
||
|
||
sessionList.appendChild(groupEl);
|
||
});
|
||
|
||
const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey();
|
||
const { visibleSessions: visibleUngroupedSessions, hiddenSessions: hiddenUngroupedSessions } = isSearchingSessions
|
||
? { visibleSessions: ungroupedSessions, hiddenSessions: [] }
|
||
: splitCollapsedSessions(ungroupedSessions, ungroupedCollapseKey);
|
||
|
||
for (const s of visibleUngroupedSessions) {
|
||
sessionList.appendChild(createSessionListItem(s));
|
||
}
|
||
|
||
if (hiddenUngroupedSessions.length > 0) {
|
||
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedSessions.length, ungroupedCollapseKey));
|
||
}
|
||
}
|
||
|
||
function startEditSessionTitle(itemEl, session) {
|
||
const titleEl = itemEl.querySelector('.session-item-title');
|
||
const currentTitle = session.title || '';
|
||
const input = document.createElement('input');
|
||
input.className = 'session-item-edit-input';
|
||
input.value = currentTitle;
|
||
input.maxLength = 100;
|
||
|
||
titleEl.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
// Hide actions during edit
|
||
const actions = itemEl.querySelector('.session-item-actions');
|
||
const time = itemEl.querySelector('.session-item-time');
|
||
if (actions) actions.style.display = 'none';
|
||
if (time) time.style.display = 'none';
|
||
|
||
function save() {
|
||
const newTitle = input.value.trim() || currentTitle;
|
||
if (newTitle !== currentTitle) {
|
||
send({ type: 'rename_session', sessionId: session.id, title: newTitle });
|
||
}
|
||
// Restore
|
||
const span = document.createElement('span');
|
||
span.className = 'session-item-title';
|
||
span.textContent = newTitle;
|
||
input.replaceWith(span);
|
||
if (actions) actions.style.display = '';
|
||
if (time) time.style.display = '';
|
||
}
|
||
|
||
input.addEventListener('blur', save);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||
if (e.key === 'Escape') { input.value = currentTitle; input.blur(); }
|
||
});
|
||
}
|
||
|
||
function highlightActiveSession() {
|
||
document.querySelectorAll('.session-item').forEach((el) => {
|
||
el.classList.toggle('active', el.dataset.id === currentSessionId);
|
||
});
|
||
}
|
||
|
||
if (chatSessionIdBtn) {
|
||
chatSessionIdBtn.addEventListener('click', () => {
|
||
copyTextToClipboard(currentSessionId, '当前会话 ID 已复制');
|
||
});
|
||
}
|
||
|
||
// --- Header title editing (contenteditable) ---
|
||
chatTitle.addEventListener('click', () => {
|
||
if (!currentSessionId || chatTitle.contentEditable === 'true') return;
|
||
const originalText = chatTitle.textContent;
|
||
chatTitle.contentEditable = 'true';
|
||
chatTitle.style.background = 'var(--surface-strong)';
|
||
chatTitle.style.outline = '1px solid var(--accent)';
|
||
chatTitle.style.borderRadius = '6px';
|
||
chatTitle.style.padding = '2px 8px';
|
||
chatTitle.style.minWidth = '96px';
|
||
chatTitle.style.whiteSpace = 'normal';
|
||
chatTitle.style.overflow = 'visible';
|
||
chatTitle.style.textOverflow = 'clip';
|
||
chatTitle.focus();
|
||
// Select all text
|
||
const range = document.createRange();
|
||
range.selectNodeContents(chatTitle);
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
|
||
function finish(save) {
|
||
chatTitle.contentEditable = 'false';
|
||
chatTitle.style.background = '';
|
||
chatTitle.style.outline = '';
|
||
chatTitle.style.borderRadius = '';
|
||
chatTitle.style.padding = '';
|
||
chatTitle.style.minWidth = '';
|
||
chatTitle.style.whiteSpace = '';
|
||
chatTitle.style.overflow = '';
|
||
chatTitle.style.textOverflow = '';
|
||
const newTitle = chatTitle.textContent.trim() || originalText;
|
||
chatTitle.textContent = newTitle;
|
||
if (save && newTitle !== originalText && currentSessionId) {
|
||
send({ type: 'rename_session', sessionId: currentSessionId, title: newTitle });
|
||
}
|
||
}
|
||
|
||
chatTitle.addEventListener('blur', () => finish(true), { once: true });
|
||
chatTitle.addEventListener('keydown', function handler(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); }
|
||
if (e.key === 'Escape') { chatTitle.textContent = originalText; chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); }
|
||
});
|
||
});
|
||
|
||
if (chatCwd) {
|
||
chatCwd.addEventListener('click', () => {
|
||
if (!currentCwd) return;
|
||
showFileBrowser();
|
||
});
|
||
}
|
||
|
||
// --- Sidebar ---
|
||
function openSidebar() {
|
||
sidebar.classList.add('open');
|
||
sidebarOverlay.hidden = false;
|
||
}
|
||
function closeSidebar() {
|
||
sidebar.classList.remove('open');
|
||
sidebarOverlay.hidden = true;
|
||
}
|
||
|
||
function canOpenSidebarBySwipe(target) {
|
||
if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false;
|
||
if (sidebar.classList.contains('open')) return false;
|
||
if (sessionLoadingOverlay && !sessionLoadingOverlay.hidden) return false;
|
||
if (!chatMain || !target || !chatMain.contains(target)) return false;
|
||
if (!app.hidden && target && target.closest('input, textarea, select, button, .modal-panel, .settings-panel, .option-picker, .cmd-menu')) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function canCloseSidebarBySwipe(target) {
|
||
if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false;
|
||
if (!sidebar.classList.contains('open')) return false;
|
||
if (!target) return false;
|
||
return sidebar.contains(target) || target === sidebarOverlay;
|
||
}
|
||
|
||
function handleSidebarSwipeStart(e) {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
const touch = e.touches[0];
|
||
if (canCloseSidebarBySwipe(e.target)) {
|
||
sidebarSwipe = {
|
||
startX: touch.clientX,
|
||
startY: touch.clientY,
|
||
active: true,
|
||
mode: 'close',
|
||
};
|
||
return;
|
||
}
|
||
if (!canOpenSidebarBySwipe(e.target)) {
|
||
sidebarSwipe = null;
|
||
return;
|
||
}
|
||
sidebarSwipe = {
|
||
startX: touch.clientX,
|
||
startY: touch.clientY,
|
||
active: true,
|
||
mode: 'open',
|
||
};
|
||
}
|
||
|
||
function handleSidebarSwipeMove(e) {
|
||
if (!sidebarSwipe?.active || !e.touches || e.touches.length !== 1) return;
|
||
const touch = e.touches[0];
|
||
const deltaX = touch.clientX - sidebarSwipe.startX;
|
||
const deltaY = touch.clientY - sidebarSwipe.startY;
|
||
if (Math.abs(deltaY) > SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT && Math.abs(deltaY) > Math.abs(deltaX)) {
|
||
sidebarSwipe = null;
|
||
return;
|
||
}
|
||
const horizontalIntent = sidebarSwipe.mode === 'open' ? deltaX > 12 : deltaX < -12;
|
||
if (horizontalIntent && Math.abs(deltaY) < SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT) {
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function handleSidebarSwipeEnd(e) {
|
||
if (!sidebarSwipe?.active) return;
|
||
const touch = e.changedTouches && e.changedTouches[0];
|
||
const endX = touch ? touch.clientX : sidebarSwipe.startX;
|
||
const endY = touch ? touch.clientY : sidebarSwipe.startY;
|
||
const deltaX = endX - sidebarSwipe.startX;
|
||
const deltaY = endY - sidebarSwipe.startY;
|
||
const shouldOpen = sidebarSwipe.mode === 'open' &&
|
||
deltaX >= SIDEBAR_SWIPE_TRIGGER &&
|
||
Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT;
|
||
const shouldClose = sidebarSwipe.mode === 'close' &&
|
||
deltaX <= -SIDEBAR_SWIPE_TRIGGER &&
|
||
Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT;
|
||
sidebarSwipe = null;
|
||
if (shouldOpen) {
|
||
openSidebar();
|
||
} else if (shouldClose) {
|
||
closeSidebar();
|
||
}
|
||
}
|
||
|
||
// --- Composer Modifier Menu ---
|
||
function getLocalSlashSuggestions(query) {
|
||
const normalized = String(query || '').replace(/^\//, '').toLowerCase();
|
||
const filtered = SLASH_COMMANDS
|
||
.filter((item) => {
|
||
const cmd = item.cmd.toLowerCase();
|
||
const desc = item.desc.toLowerCase();
|
||
return !normalized || cmd.includes(normalized) || desc.includes(normalized);
|
||
})
|
||
.sort((a, b) => (b.cmd === `/${normalized}` ? 1 : 0) - (a.cmd === `/${normalized}` ? 1 : 0));
|
||
return filtered.map((item) => ({
|
||
kind: 'command',
|
||
name: item.cmd,
|
||
label: item.cmd,
|
||
description: item.desc,
|
||
insertion: `${item.cmd} `,
|
||
appendSpace: false,
|
||
}));
|
||
}
|
||
|
||
function findActiveComposerToken() {
|
||
if (!msgInput) return null;
|
||
const value = msgInput.value || '';
|
||
const cursor = typeof msgInput.selectionStart === 'number' ? msgInput.selectionStart : value.length;
|
||
const before = value.slice(0, cursor);
|
||
|
||
if (!before.includes('\n') && value.startsWith('/') && cursor > 0) {
|
||
const nextWhitespace = value.search(/\s/);
|
||
const end = nextWhitespace >= 0 ? Math.min(cursor, nextWhitespace) : cursor;
|
||
if (cursor <= end || nextWhitespace < 0) {
|
||
return { trigger: '/', query: value.slice(1, cursor), start: 0, end: cursor };
|
||
}
|
||
}
|
||
|
||
const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
|
||
const line = before.slice(lineStart);
|
||
const match = line.match(/(^|\s)([@$])([^\s]*)$/);
|
||
if (!match) return null;
|
||
const prefixLength = match[1] ? match[1].length : 0;
|
||
const start = lineStart + match.index + prefixLength;
|
||
return {
|
||
trigger: match[2],
|
||
query: match[3] || '',
|
||
start,
|
||
end: cursor,
|
||
};
|
||
}
|
||
|
||
function showCmdMenu(token, items) {
|
||
const safeItems = Array.isArray(items) ? items : [];
|
||
if (!token || safeItems.length === 0) {
|
||
hideCmdMenu();
|
||
return;
|
||
}
|
||
activeComposerToken = token;
|
||
cmdMenuIndex = 0;
|
||
cmdMenu.innerHTML = safeItems.map((item, i) => {
|
||
const kindLabel = item.kind === 'skill'
|
||
? 'Skill'
|
||
: item.kind === 'prompt'
|
||
? 'Prompt'
|
||
: item.kind === 'file'
|
||
? (item.itemType === 'directory' ? 'Dir' : 'File')
|
||
: item.kind === 'mcp'
|
||
? 'MCP'
|
||
: 'Cmd';
|
||
return `<div class="cmd-item${i === 0 ? ' active' : ''}" data-index="${i}">
|
||
<span class="cmd-item-kind">${kindLabel}</span>
|
||
<span class="cmd-item-main">
|
||
<span class="cmd-item-cmd">${escapeHtml(item.label || item.name || item.insertion || '')}</span>
|
||
<span class="cmd-item-desc">${escapeHtml(item.description || item.title || '')}</span>
|
||
</span>
|
||
</div>`;
|
||
}).join('');
|
||
cmdMenu._items = safeItems;
|
||
cmdMenu.hidden = false;
|
||
|
||
cmdMenu.querySelectorAll('.cmd-item').forEach((el) => {
|
||
el.addEventListener('click', () => {
|
||
const index = Number.parseInt(el.dataset.index || '-1', 10);
|
||
selectComposerItemByIndex(index);
|
||
});
|
||
});
|
||
}
|
||
|
||
function requestComposerSuggestions() {
|
||
const token = findActiveComposerToken();
|
||
if (!token || noteMode) {
|
||
hideCmdMenu();
|
||
return;
|
||
}
|
||
|
||
if (token.trigger === '/') {
|
||
showCmdMenu(token, getLocalSlashSuggestions(token.query));
|
||
}
|
||
|
||
clearTimeout(composerSuggestionTimer);
|
||
composerSuggestionTimer = setTimeout(() => {
|
||
const liveToken = findActiveComposerToken();
|
||
if (!liveToken || liveToken.trigger !== token.trigger || liveToken.start !== token.start) {
|
||
hideCmdMenu();
|
||
return;
|
||
}
|
||
const requestId = `composer-${Date.now()}-${++composerRequestSeq}`;
|
||
latestComposerRequestId = requestId;
|
||
activeComposerToken = liveToken;
|
||
send({
|
||
type: 'composer_suggestions',
|
||
requestId,
|
||
trigger: liveToken.trigger,
|
||
query: liveToken.query,
|
||
sessionId: currentSessionId,
|
||
agent: currentAgent,
|
||
});
|
||
}, COMPOSER_SUGGESTION_DEBOUNCE);
|
||
}
|
||
|
||
function handleComposerSuggestions(msg) {
|
||
if (!msg || msg.requestId !== latestComposerRequestId) return;
|
||
const token = findActiveComposerToken();
|
||
if (!token || token.trigger !== msg.trigger) {
|
||
hideCmdMenu();
|
||
return;
|
||
}
|
||
showCmdMenu(token, msg.items || []);
|
||
}
|
||
|
||
function hideCmdMenu() {
|
||
cmdMenu.hidden = true;
|
||
cmdMenuIndex = -1;
|
||
cmdMenu._items = [];
|
||
activeComposerToken = null;
|
||
}
|
||
|
||
function navigateCmdMenu(direction) {
|
||
const items = cmdMenu.querySelectorAll('.cmd-item');
|
||
if (items.length === 0) return;
|
||
items[cmdMenuIndex]?.classList.remove('active');
|
||
cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length;
|
||
items[cmdMenuIndex]?.classList.add('active');
|
||
items[cmdMenuIndex]?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
function selectComposerItemByIndex(index) {
|
||
const items = Array.isArray(cmdMenu._items) ? cmdMenu._items : [];
|
||
const item = items[index];
|
||
if (!item) return;
|
||
|
||
if (item.kind === 'command') {
|
||
const cmd = item.name || item.label || '';
|
||
if (cmd === '/model') {
|
||
hideCmdMenu();
|
||
msgInput.value = '';
|
||
showModelPicker();
|
||
return;
|
||
}
|
||
if (cmd === '/mode') {
|
||
hideCmdMenu();
|
||
msgInput.value = '';
|
||
showModePicker();
|
||
return;
|
||
}
|
||
msgInput.value = item.insertion || `${cmd} `;
|
||
hideCmdMenu();
|
||
msgInput.focus();
|
||
autoResize();
|
||
return;
|
||
}
|
||
|
||
const token = activeComposerToken || findActiveComposerToken();
|
||
if (!token) return;
|
||
const value = msgInput.value || '';
|
||
const insertion = String(item.insertion || item.label || item.name || '');
|
||
const appendSpace = item.appendSpace !== false;
|
||
const suffix = appendSpace ? ' ' : '';
|
||
msgInput.value = value.slice(0, token.start) + insertion + suffix + value.slice(token.end);
|
||
const nextCursor = token.start + insertion.length + suffix.length;
|
||
msgInput.setSelectionRange(nextCursor, nextCursor);
|
||
hideCmdMenu();
|
||
msgInput.focus();
|
||
autoResize();
|
||
if (!appendSpace) requestComposerSuggestions();
|
||
}
|
||
|
||
function selectCmdMenuItem() {
|
||
if (cmdMenuIndex >= 0) selectComposerItemByIndex(cmdMenuIndex);
|
||
}
|
||
|
||
// --- Option Picker (generic) ---
|
||
function showOptionPicker(title, options, currentValue, onSelect) {
|
||
hideOptionPicker();
|
||
|
||
const picker = document.createElement('div');
|
||
picker.className = 'option-picker';
|
||
picker.id = 'option-picker';
|
||
|
||
picker.innerHTML = `
|
||
<div class="option-picker-title">${escapeHtml(title)}</div>
|
||
${options.map(opt => `
|
||
<div class="option-picker-item${opt.value === currentValue ? ' active' : ''}" data-value="${opt.value}">
|
||
<div class="option-picker-item-info">
|
||
<div class="option-picker-item-label">${escapeHtml(opt.label)}</div>
|
||
<div class="option-picker-item-desc">${escapeHtml(opt.desc)}</div>
|
||
</div>
|
||
${opt.value === currentValue ? '<span class="option-picker-item-check">✓</span>' : ''}
|
||
</div>
|
||
`).join('')}
|
||
`;
|
||
|
||
const chatMain = document.querySelector('.chat-main');
|
||
chatMain.appendChild(picker);
|
||
|
||
picker.querySelectorAll('.option-picker-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
// Close current picker first so onSelect can safely open a nested picker.
|
||
const v = el.dataset.value;
|
||
hideOptionPicker();
|
||
onSelect(v);
|
||
});
|
||
});
|
||
|
||
// Close on outside click (delayed to avoid immediate close)
|
||
setTimeout(() => {
|
||
document.addEventListener('click', _pickerOutsideClick);
|
||
}, 0);
|
||
document.addEventListener('keydown', _pickerEscape);
|
||
}
|
||
|
||
function hideOptionPicker() {
|
||
const picker = document.getElementById('option-picker');
|
||
if (picker) picker.remove();
|
||
document.removeEventListener('click', _pickerOutsideClick);
|
||
document.removeEventListener('keydown', _pickerEscape);
|
||
}
|
||
|
||
function _pickerOutsideClick(e) {
|
||
const picker = document.getElementById('option-picker');
|
||
if (picker && !picker.contains(e.target)) {
|
||
hideOptionPicker();
|
||
}
|
||
}
|
||
|
||
function _pickerEscape(e) {
|
||
if (e.key === 'Escape') {
|
||
hideOptionPicker();
|
||
}
|
||
}
|
||
|
||
function showModelPicker() {
|
||
if (isCodexLikeAgent(currentAgent)) {
|
||
const current = _splitCodexThinkingModel(currentModel || '');
|
||
const baseOptions = getCodexBaseModelOptions();
|
||
showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => {
|
||
const base = String(baseValue || '').trim();
|
||
const thinkingOptions = [
|
||
{ value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' },
|
||
{ value: 'low', label: 'low', desc: '较轻 thinking' },
|
||
{ value: 'medium', label: 'medium', desc: '中等 thinking' },
|
||
{ value: 'high', label: 'high', desc: '更强 thinking' },
|
||
{ value: 'xhigh', label: 'xhigh', desc: '最强 thinking' },
|
||
];
|
||
showOptionPicker('选择 Thinking 强度', thinkingOptions, current.level || '', (lvl) => {
|
||
const level = String(lvl || '').trim().toLowerCase();
|
||
const full = level ? `${base}(${level})` : base;
|
||
send({ type: 'message', text: `/model ${full}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => {
|
||
send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
||
});
|
||
}
|
||
|
||
function showModePicker() {
|
||
showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => {
|
||
currentMode = value;
|
||
modeSelect.value = currentMode;
|
||
localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
|
||
if (currentSessionId) {
|
||
send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Send Message ---
|
||
function submitUserMessage(text, attachments = []) {
|
||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||
if (welcome) welcome.remove();
|
||
const messageId = createLocalId('user');
|
||
const element = createMsgElement('user', text, attachments, { messageId });
|
||
messagesDiv.appendChild(element);
|
||
if (currentSessionId) {
|
||
const messageIndex = currentSessionMessageCount;
|
||
currentSessionMessageCount += 1;
|
||
markSessionMessageElement(element, messageIndex);
|
||
}
|
||
registerUserMessage(messageId, element, text);
|
||
updateUserOutlinePanel();
|
||
scrollToBottom();
|
||
|
||
send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
||
startGenerating();
|
||
}
|
||
|
||
function sendMessage() {
|
||
const text = msgInput.value.trim();
|
||
if (noteMode) {
|
||
if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
|
||
hideCmdMenu();
|
||
hideOptionPicker();
|
||
if (pendingAttachments.length > 0) {
|
||
appendError('笔记模式暂不支持附带图片,请先移除图片。');
|
||
return;
|
||
}
|
||
if (addPendingNoteFromInput(text)) {
|
||
msgInput.value = '';
|
||
autoResize();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent);
|
||
if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
|
||
if (isGenerating && !runtimeInsert) return;
|
||
hideCmdMenu();
|
||
hideOptionPicker();
|
||
|
||
if (runtimeInsert) {
|
||
if (pendingAttachments.length > 0) {
|
||
appendError('Codex App 运行中插入暂不支持图片附件,请先移除图片。');
|
||
return;
|
||
}
|
||
if (text.startsWith('/')) {
|
||
appendError('Codex App 运行中暂不支持 slash 指令插入。');
|
||
return;
|
||
}
|
||
const messageId = createLocalId('user');
|
||
const element = createMsgElement('user', text, [], { messageId, codexAppSteerStatus: 'pending' });
|
||
const streamEl = document.getElementById('streaming-msg');
|
||
const shouldFollow = isNearBottom();
|
||
if (streamEl && streamEl.parentNode === messagesDiv) {
|
||
messagesDiv.insertBefore(element, streamEl);
|
||
} else {
|
||
messagesDiv.appendChild(element);
|
||
}
|
||
if (currentSessionId) {
|
||
const messageIndex = currentSessionMessageCount;
|
||
currentSessionMessageCount += 1;
|
||
markSessionMessageElement(element, messageIndex);
|
||
}
|
||
registerUserMessage(messageId, element, text);
|
||
updateUserOutlinePanel();
|
||
if (shouldFollow) {
|
||
scrollToBottom();
|
||
} else {
|
||
updateScrollbar();
|
||
}
|
||
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, clientMessageId: messageId });
|
||
msgInput.value = '';
|
||
autoResize();
|
||
return;
|
||
}
|
||
|
||
// Slash commands: don't show as user bubble
|
||
if (text.startsWith('/')) {
|
||
if (pendingAttachments.length > 0) {
|
||
appendError('命令消息暂不支持附带图片,请先移除图片或发送普通消息。');
|
||
return;
|
||
}
|
||
// /model without argument → show interactive picker
|
||
if (text === '/model' || text === '/model ') {
|
||
showModelPicker();
|
||
msgInput.value = '';
|
||
autoResize();
|
||
return;
|
||
}
|
||
// /mode without argument → show interactive picker
|
||
if (text === '/mode' || text === '/mode ') {
|
||
showModePicker();
|
||
msgInput.value = '';
|
||
autoResize();
|
||
return;
|
||
}
|
||
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
||
msgInput.value = '';
|
||
autoResize();
|
||
return;
|
||
}
|
||
|
||
// Regular message
|
||
const attachments = pendingAttachments.map((attachment) => ({ ...attachment }));
|
||
submitUserMessage(text, attachments);
|
||
msgInput.value = '';
|
||
pendingAttachments = [];
|
||
renderPendingAttachments();
|
||
autoResize();
|
||
}
|
||
|
||
function autoResize() {
|
||
msgInput.style.height = 'auto';
|
||
const max = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--input-max-height')) || 200;
|
||
msgInput.style.height = Math.min(msgInput.scrollHeight, max) + 'px';
|
||
}
|
||
|
||
function isMobileInputMode() {
|
||
return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches;
|
||
}
|
||
|
||
// --- Event Listeners ---
|
||
loginForm.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const pw = loginPassword.value;
|
||
if (!pw) return;
|
||
loginError.hidden = true;
|
||
loginPasswordValue = pw;
|
||
// Remember password
|
||
if (rememberPw.checked) {
|
||
localStorage.setItem('cc-web-pw', pw);
|
||
} else {
|
||
localStorage.removeItem('cc-web-pw');
|
||
}
|
||
send({ type: 'auth', password: pw });
|
||
// Request notification permission on first user interaction
|
||
requestNotificationPermission();
|
||
});
|
||
|
||
menuBtn.addEventListener('click', () => {
|
||
sidebar.classList.contains('open') ? closeSidebar() : openSidebar();
|
||
});
|
||
|
||
sidebarOverlay.addEventListener('click', closeSidebar);
|
||
document.addEventListener('touchstart', handleSidebarSwipeStart, { passive: true });
|
||
document.addEventListener('touchmove', handleSidebarSwipeMove, { passive: false });
|
||
document.addEventListener('touchend', handleSidebarSwipeEnd, { passive: true });
|
||
document.addEventListener('touchcancel', () => { sidebarSwipe = null; }, { passive: true });
|
||
|
||
if (chatAgentBtn && chatAgentMenu) {
|
||
chatAgentBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleAgentMenu();
|
||
});
|
||
chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
closeAgentMenu();
|
||
const targetAgent = normalizeAgent(btn.dataset.agent);
|
||
if (targetAgent === currentAgent) return;
|
||
syncViewForAgent(targetAgent, { preserveCurrent: false, loadLast: true });
|
||
});
|
||
});
|
||
}
|
||
|
||
if (userOutlineBtn && userOutlinePanel) {
|
||
userOutlineBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleUserOutlinePanel();
|
||
});
|
||
userOutlinePanel.addEventListener('click', (e) => {
|
||
const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null;
|
||
if (!target) return;
|
||
const anchorId = target.getAttribute('data-target') || '';
|
||
closeUserOutlinePanel();
|
||
scrollToMessage(anchorId);
|
||
});
|
||
}
|
||
|
||
if (ccwebPromptOutlineBtn && ccwebPromptOutlinePanel) {
|
||
ccwebPromptOutlineBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleCcwebPromptOutlinePanel();
|
||
});
|
||
ccwebPromptOutlinePanel.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
}
|
||
|
||
if (reloadMcpBtn) {
|
||
reloadMcpBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
reloadCurrentMcpServers();
|
||
});
|
||
}
|
||
|
||
if (sessionSearchInput) {
|
||
sessionSearchInput.addEventListener('input', () => {
|
||
sessionSearchQuery = sessionSearchInput.value;
|
||
renderSessionList();
|
||
});
|
||
sessionSearchInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && normalizeSessionSearchQuery(sessionSearchQuery)) {
|
||
e.stopPropagation();
|
||
sessionSearchQuery = '';
|
||
renderSessionList();
|
||
sessionSearchInput.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (sessionSearchClear) {
|
||
sessionSearchClear.addEventListener('click', () => {
|
||
sessionSearchQuery = '';
|
||
renderSessionList();
|
||
sessionSearchInput?.focus();
|
||
});
|
||
}
|
||
|
||
// Split new-chat button
|
||
newChatBtn.addEventListener('click', () => showNewSessionModal());
|
||
newChatArrow.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
newChatDropdown.hidden = !newChatDropdown.hidden;
|
||
});
|
||
importSessionBtn.addEventListener('click', () => {
|
||
newChatDropdown.hidden = true;
|
||
if (isCodexLikeAgent(currentAgent)) {
|
||
showImportCodexSessionModal();
|
||
} else {
|
||
showImportSessionModal();
|
||
}
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!newChatDropdown.hidden &&
|
||
!newChatDropdown.contains(e.target) &&
|
||
e.target !== newChatArrow) {
|
||
newChatDropdown.hidden = true;
|
||
}
|
||
if (chatAgentMenu && !chatAgentMenu.hidden &&
|
||
!chatAgentMenu.contains(e.target) &&
|
||
e.target !== chatAgentBtn) {
|
||
closeAgentMenu();
|
||
}
|
||
if (userOutlinePanel && !userOutlinePanel.hidden &&
|
||
!userOutlinePanel.contains(e.target) &&
|
||
e.target !== userOutlineBtn) {
|
||
closeUserOutlinePanel();
|
||
}
|
||
if (ccwebPromptOutlinePanel && !ccwebPromptOutlinePanel.hidden &&
|
||
!ccwebPromptOutlinePanel.contains(e.target) &&
|
||
e.target !== ccwebPromptOutlineBtn) {
|
||
closeCcwebPromptOutlinePanel();
|
||
}
|
||
});
|
||
sendBtn.addEventListener('click', sendMessage);
|
||
if (queueSendBtn) {
|
||
queueSendBtn.addEventListener('click', queueMessageFromInput);
|
||
}
|
||
if (noteModeBtn) {
|
||
noteModeBtn.addEventListener('click', () => {
|
||
noteMode = !noteMode;
|
||
updateNoteModeUI();
|
||
msgInput.focus();
|
||
});
|
||
}
|
||
abortBtn.addEventListener('click', () => send({ type: 'abort' }));
|
||
if (attachBtn && imageUploadInput) {
|
||
attachBtn.addEventListener('click', () => imageUploadInput.click());
|
||
imageUploadInput.addEventListener('change', () => {
|
||
handleSelectedImageFiles(imageUploadInput.files);
|
||
});
|
||
}
|
||
if (inputWrapper) {
|
||
inputWrapper.addEventListener('dragover', (e) => {
|
||
if (!e.dataTransfer?.types?.includes('Files')) return;
|
||
e.preventDefault();
|
||
inputWrapper.classList.add('drag-active');
|
||
});
|
||
inputWrapper.addEventListener('dragleave', (e) => {
|
||
if (e.target === inputWrapper) inputWrapper.classList.remove('drag-active');
|
||
});
|
||
inputWrapper.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
inputWrapper.classList.remove('drag-active');
|
||
handleSelectedImageFiles(e.dataTransfer?.files);
|
||
});
|
||
}
|
||
|
||
// Mode selector
|
||
modeSelect.value = currentMode;
|
||
modeSelect.addEventListener('change', () => {
|
||
currentMode = modeSelect.value;
|
||
localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
|
||
if (currentSessionId) {
|
||
send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
|
||
}
|
||
});
|
||
|
||
msgInput.addEventListener('input', () => {
|
||
autoResize();
|
||
requestComposerSuggestions();
|
||
});
|
||
|
||
msgInput.addEventListener('keydown', (e) => {
|
||
// Command menu navigation
|
||
if (!cmdMenu.hidden) {
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; }
|
||
if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; }
|
||
if (e.key === 'Escape') { hideCmdMenu(); closeSessionActionMenus(); return; }
|
||
}
|
||
if (e.key === 'Escape') {
|
||
closeSessionActionMenus();
|
||
}
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
if (isMobileInputMode()) {
|
||
if (!cmdMenu.hidden) {
|
||
e.preventDefault();
|
||
selectCmdMenuItem();
|
||
}
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
if (!cmdMenu.hidden) {
|
||
// If menu is open and user presses Enter, select the item
|
||
selectCmdMenuItem();
|
||
} else {
|
||
sendMessage();
|
||
}
|
||
}
|
||
});
|
||
|
||
msgInput.addEventListener('paste', (e) => {
|
||
const items = Array.from(e.clipboardData?.items || []);
|
||
const files = items
|
||
.filter((item) => item.kind === 'file' && /^image\//.test(item.type || ''))
|
||
.map((item) => item.getAsFile())
|
||
.filter(Boolean);
|
||
if (files.length > 0) {
|
||
e.preventDefault();
|
||
handleSelectedImageFiles(files);
|
||
}
|
||
});
|
||
|
||
// Close cmd menu on outside click
|
||
document.addEventListener('click', (e) => {
|
||
if (!(e.target instanceof Element) || !e.target.closest('.session-item-more')) {
|
||
closeSessionActionMenus();
|
||
}
|
||
if (!cmdMenu.contains(e.target) && e.target !== msgInput) {
|
||
hideCmdMenu();
|
||
}
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closeSessionActionMenus();
|
||
});
|
||
|
||
// --- Toast Notification ---
|
||
function showToast(text, sessionId) {
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast-notification';
|
||
toast.textContent = text;
|
||
if (sessionId) {
|
||
toast.style.cursor = 'pointer';
|
||
toast.addEventListener('click', () => {
|
||
openSession(sessionId);
|
||
toast.remove();
|
||
});
|
||
}
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.classList.add('show'), 10);
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
|
||
// --- Browser Notification (via Service Worker for mobile) ---
|
||
function showBrowserNotification(title) {
|
||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.ready.then((reg) => {
|
||
reg.showNotification('CC-Web', {
|
||
body: `「${title}」任务完成`,
|
||
icon: '/icon-192.png',
|
||
tag: 'cc-web-task',
|
||
renotify: true,
|
||
});
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function requestNotificationPermission() {
|
||
if ('Notification' in window && Notification.permission === 'default') {
|
||
Notification.requestPermission();
|
||
}
|
||
}
|
||
|
||
// --- Settings Panel ---
|
||
let _onNotifyConfig = null;
|
||
let _onNotifyTestResult = null;
|
||
let _onModelConfig = null;
|
||
let _onCodexConfig = null;
|
||
let _onFetchModelsResult = null;
|
||
let _onCodexSessions = null;
|
||
|
||
const settingsBtn = $('#settings-btn');
|
||
|
||
const PROVIDER_OPTIONS = [
|
||
{ value: 'off', label: '关闭' },
|
||
{ value: 'pushplus', label: 'PushPlus' },
|
||
{ value: 'telegram', label: 'Telegram' },
|
||
{ value: 'serverchan', label: 'Server酱' },
|
||
{ value: 'feishu', label: '飞书机器人' },
|
||
{ value: 'qqbot', label: 'QQ(Qmsg)' },
|
||
];
|
||
|
||
function buildNotifyFieldsHtml(config, provider) {
|
||
if (provider === 'pushplus') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Token</label>
|
||
<input type="text" id="notify-pushplus-token" placeholder="PushPlus Token" value="${escapeHtml(config?.pushplus?.token || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'telegram') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Bot Token</label>
|
||
<input type="text" id="notify-tg-bottoken" placeholder="123456:ABC-DEF..." value="${escapeHtml(config?.telegram?.botToken || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Chat ID</label>
|
||
<input type="text" id="notify-tg-chatid" placeholder="Chat ID" value="${escapeHtml(config?.telegram?.chatId || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'serverchan') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>SendKey</label>
|
||
<input type="text" id="notify-sc-sendkey" placeholder="Server酱 SendKey" value="${escapeHtml(config?.serverchan?.sendKey || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'feishu') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Webhook 地址</label>
|
||
<input type="text" id="notify-feishu-webhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" value="${escapeHtml(config?.feishu?.webhook || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'qqbot') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Qmsg Key</label>
|
||
<input type="text" id="notify-qmsg-key" placeholder="Qmsg 推送 Key" value="${escapeHtml(config?.qqbot?.qmsgKey || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function buildAgentContextCard(agent, title, copy) {
|
||
const label = AGENT_LABELS[normalizeAgent(agent)] || AGENT_LABELS.claude;
|
||
return `
|
||
<div class="agent-context-card">
|
||
<div class="agent-context-kicker">${escapeHtml(label)} Space</div>
|
||
<div class="agent-context-title">${escapeHtml(title)}</div>
|
||
<div class="agent-context-copy">${escapeHtml(copy)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderNotifyFields(fieldsDiv, config, provider) {
|
||
fieldsDiv.innerHTML = buildNotifyFieldsHtml(config, provider);
|
||
}
|
||
|
||
function collectNotifyConfigFromPanel(panel, currentConfig, provider) {
|
||
const pp = panel.querySelector('#notify-pushplus-token');
|
||
const tgBot = panel.querySelector('#notify-tg-bottoken');
|
||
const tgChat = panel.querySelector('#notify-tg-chatid');
|
||
const sc = panel.querySelector('#notify-sc-sendkey');
|
||
const feishuWh = panel.querySelector('#notify-feishu-webhook');
|
||
const qmsgKey = panel.querySelector('#notify-qmsg-key');
|
||
// Summary config
|
||
const summaryEnabled = panel.querySelector('#notify-summary-enabled');
|
||
const summaryTrigger = panel.querySelector('#notify-summary-trigger');
|
||
const summarySource = panel.querySelector('#notify-summary-source');
|
||
const summaryApiBase = panel.querySelector('#notify-summary-apibase');
|
||
const summaryApiKey = panel.querySelector('#notify-summary-apikey');
|
||
const summaryModel = panel.querySelector('#notify-summary-model');
|
||
const cs = currentConfig?.summary || {};
|
||
return {
|
||
provider,
|
||
pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') },
|
||
telegram: {
|
||
botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''),
|
||
chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || ''),
|
||
},
|
||
serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') },
|
||
feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') },
|
||
qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') },
|
||
summary: {
|
||
enabled: summaryEnabled ? summaryEnabled.checked : !!cs.enabled,
|
||
trigger: summaryTrigger ? summaryTrigger.value : (cs.trigger || 'background'),
|
||
apiSource: summarySource ? summarySource.value : (cs.apiSource || 'claude'),
|
||
apiBase: summaryApiBase ? summaryApiBase.value.trim() : (cs.apiBase || ''),
|
||
apiKey: summaryApiKey ? summaryApiKey.value.trim() : (cs.apiKey || ''),
|
||
model: summaryModel ? summaryModel.value.trim() : (cs.model || ''),
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildSummarySettingsHtml(config) {
|
||
const s = config?.summary || {};
|
||
const enabled = !!s.enabled;
|
||
const trigger = s.trigger || 'background';
|
||
const src = s.apiSource || 'claude';
|
||
const customVisible = src === 'custom' ? '' : 'display:none';
|
||
return `
|
||
<div class="settings-divider"></div>
|
||
<div class="settings-section-title">通知摘要</div>
|
||
<div class="settings-field" style="flex-direction:row;align-items:center;gap:10px">
|
||
<label style="margin:0;flex:1">启用 AI 摘要</label>
|
||
<input type="checkbox" id="notify-summary-enabled" ${enabled ? 'checked' : ''} style="width:auto;margin:0">
|
||
</div>
|
||
<div id="notify-summary-options" style="${enabled ? '' : 'display:none'}">
|
||
<div class="settings-field">
|
||
<label>推送时机</label>
|
||
<select class="settings-select" id="notify-summary-trigger">
|
||
<option value="background" ${trigger === 'background' ? 'selected' : ''}>仅后台任务</option>
|
||
<option value="always" ${trigger === 'always' ? 'selected' : ''}>所有任务</option>
|
||
</select>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>摘要 API 来源</label>
|
||
<select class="settings-select" id="notify-summary-source">
|
||
<option value="claude" ${src === 'claude' ? 'selected' : ''}>Claude 活跃模板</option>
|
||
<option value="codex" ${src === 'codex' ? 'selected' : ''}>Codex 活跃 Profile</option>
|
||
<option value="custom" ${src === 'custom' ? 'selected' : ''}>独立配置</option>
|
||
</select>
|
||
</div>
|
||
<div id="notify-summary-custom" style="${customVisible}">
|
||
<div class="settings-field">
|
||
<label>API Base URL</label>
|
||
<input type="text" id="notify-summary-apibase" placeholder="https://api.example.com" value="${escapeHtml(s.apiBase || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Key</label>
|
||
<input type="text" id="notify-summary-apikey" placeholder="sk-..." value="${escapeHtml(s.apiKey || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>模型</label>
|
||
<input type="text" id="notify-summary-model" placeholder="claude-opus-4-6" value="${escapeHtml(s.model || '')}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function bindSummarySettingsEvents(panel) {
|
||
const enabledCb = panel.querySelector('#notify-summary-enabled');
|
||
const optionsDiv = panel.querySelector('#notify-summary-options');
|
||
const sourceSelect = panel.querySelector('#notify-summary-source');
|
||
const customDiv = panel.querySelector('#notify-summary-custom');
|
||
if (!enabledCb || !optionsDiv || !sourceSelect || !customDiv) return;
|
||
enabledCb.addEventListener('change', () => {
|
||
optionsDiv.style.display = enabledCb.checked ? '' : 'none';
|
||
});
|
||
sourceSelect.addEventListener('change', () => {
|
||
customDiv.style.display = sourceSelect.value === 'custom' ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function openPasswordModal() {
|
||
const pwOverlay = document.createElement('div');
|
||
pwOverlay.className = 'settings-overlay';
|
||
pwOverlay.style.zIndex = '10001';
|
||
const pwModal = document.createElement('div');
|
||
pwModal.className = 'settings-panel';
|
||
pwModal.style.maxWidth = '400px';
|
||
pwModal.innerHTML = `
|
||
<div class="settings-header">
|
||
<h3>修改密码</h3>
|
||
<button class="settings-close" id="pw-modal-close">×</button>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>当前密码</label>
|
||
<input type="password" id="pw-modal-current" placeholder="当前密码" autocomplete="current-password">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>新密码</label>
|
||
<input type="password" id="pw-modal-new" placeholder="新密码" autocomplete="new-password">
|
||
<div class="password-hint" id="pw-modal-hint">至少 8 位,包含大写/小写/数字/特殊字符中的 2 种</div>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>确认新密码</label>
|
||
<input type="password" id="pw-modal-confirm" placeholder="确认新密码" autocomplete="new-password">
|
||
</div>
|
||
<div class="settings-actions">
|
||
<button class="btn-save" id="pw-modal-submit" disabled>修改密码</button>
|
||
</div>
|
||
<div class="settings-status" id="pw-modal-status"></div>
|
||
`;
|
||
pwOverlay.appendChild(pwModal);
|
||
document.body.appendChild(pwOverlay);
|
||
|
||
const currentPwIn = pwModal.querySelector('#pw-modal-current');
|
||
const newPwIn = pwModal.querySelector('#pw-modal-new');
|
||
const confirmPwIn = pwModal.querySelector('#pw-modal-confirm');
|
||
const hint = pwModal.querySelector('#pw-modal-hint');
|
||
const submitBtn = pwModal.querySelector('#pw-modal-submit');
|
||
const status = pwModal.querySelector('#pw-modal-status');
|
||
|
||
function checkPw() {
|
||
const newPw = newPwIn.value;
|
||
const confirmPw = confirmPwIn.value;
|
||
const currentPw = currentPwIn.value;
|
||
if (!newPw) {
|
||
hint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
|
||
hint.className = 'password-hint';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
const result = clientValidatePassword(newPw);
|
||
if (!result.valid) {
|
||
hint.textContent = result.message;
|
||
hint.className = 'password-hint error';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
hint.textContent = '密码强度符合要求';
|
||
hint.className = 'password-hint success';
|
||
submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
|
||
}
|
||
|
||
currentPwIn.addEventListener('input', checkPw);
|
||
newPwIn.addEventListener('input', checkPw);
|
||
confirmPwIn.addEventListener('input', checkPw);
|
||
|
||
const closePwModal = () => { document.body.removeChild(pwOverlay); };
|
||
pwModal.querySelector('#pw-modal-close').addEventListener('click', closePwModal);
|
||
pwOverlay.addEventListener('click', (e) => { if (e.target === pwOverlay) closePwModal(); });
|
||
|
||
submitBtn.addEventListener('click', () => {
|
||
const currentPw = currentPwIn.value;
|
||
const newPw = newPwIn.value;
|
||
const confirmPw = confirmPwIn.value;
|
||
if (newPw !== confirmPw) {
|
||
status.textContent = '两次密码不一致';
|
||
status.className = 'settings-status error';
|
||
return;
|
||
}
|
||
submitBtn.disabled = true;
|
||
status.textContent = '正在修改...';
|
||
status.className = 'settings-status';
|
||
_onPasswordChanged = (result) => {
|
||
if (result.success) {
|
||
status.textContent = result.message || '密码修改成功';
|
||
status.className = 'settings-status success';
|
||
setTimeout(closePwModal, 1200);
|
||
} else {
|
||
status.textContent = result.message || '修改失败';
|
||
status.className = 'settings-status error';
|
||
submitBtn.disabled = false;
|
||
}
|
||
};
|
||
send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw });
|
||
});
|
||
|
||
currentPwIn.focus();
|
||
}
|
||
|
||
function showCodexSettingsPanel() {
|
||
send({ type: 'get_codex_config' });
|
||
send({ type: 'get_notify_config' });
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay';
|
||
overlay.id = 'settings-overlay';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'settings-panel';
|
||
panel.innerHTML = `
|
||
<h3>
|
||
⚙ Codex 设置
|
||
<button class="settings-close" title="关闭">×</button>
|
||
</h3>
|
||
|
||
<div class="settings-section-title">Codex 运行配置</div>
|
||
<div class="settings-field">
|
||
<label>配置模式</label>
|
||
<select class="settings-select" id="codex-mode">
|
||
<option value="local">读取本机 Codex 登录态 / ~/.codex/config.toml</option>
|
||
<option value="custom">自定义 API Profile</option>
|
||
</select>
|
||
</div>
|
||
<div id="codex-profile-area"></div>
|
||
|
||
<div class="settings-divider"></div>
|
||
<div class="settings-section-title">容量失败重试</div>
|
||
<div class="settings-field">
|
||
<label>自动重试</label>
|
||
<select class="settings-select" id="codex-retry-mode">
|
||
<option value="limited">按次数重试</option>
|
||
<option value="forever">一直重试</option>
|
||
<option value="off">关闭</option>
|
||
</select>
|
||
</div>
|
||
<div class="settings-retry-grid">
|
||
<div class="settings-field">
|
||
<label>间隔(秒)</label>
|
||
<input type="number" id="codex-retry-interval" min="1" max="3600" step="1" inputmode="numeric">
|
||
</div>
|
||
<div class="settings-field" id="codex-retry-attempts-field">
|
||
<label>重试次数</label>
|
||
<input type="number" id="codex-retry-attempts" min="1" max="1000" step="1" inputmode="numeric">
|
||
</div>
|
||
</div>
|
||
<div class="settings-inline-note" id="codex-retry-note">
|
||
仅对没有产生文本或工具调用的 Codex 容量/过载失败生效,避免重复执行已有副作用的任务。
|
||
</div>
|
||
|
||
<div class="settings-actions">
|
||
<button class="btn-save" id="codex-save-btn">保存 Codex 配置</button>
|
||
</div>
|
||
<div class="settings-status" id="codex-status"></div>
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
${buildAppearanceSettingsHtml()}
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
${buildNotifyEntryHtml(null)}
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
<div class="settings-section-title">系统</div>
|
||
<div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
|
||
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
||
<button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
|
||
</div>
|
||
<div class="settings-status" id="update-status" style="margin-top:8px"></div>
|
||
`;
|
||
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
mountAppearanceSettings(panel);
|
||
const notifyPageBtn = panel.querySelector('[data-open-notify-page]');
|
||
if (notifyPageBtn) notifyPageBtn.addEventListener('click', openNotifySubpage);
|
||
|
||
const closeBtn = panel.querySelector('.settings-close');
|
||
const codexModeSelect = panel.querySelector('#codex-mode');
|
||
const codexProfileArea = panel.querySelector('#codex-profile-area');
|
||
const codexRetryModeSelect = panel.querySelector('#codex-retry-mode');
|
||
const codexRetryIntervalInput = panel.querySelector('#codex-retry-interval');
|
||
const codexRetryAttemptsInput = panel.querySelector('#codex-retry-attempts');
|
||
const codexRetryAttemptsField = panel.querySelector('#codex-retry-attempts-field');
|
||
const codexRetryNote = panel.querySelector('#codex-retry-note');
|
||
const codexStatus = panel.querySelector('#codex-status');
|
||
const codexSaveBtn = panel.querySelector('#codex-save-btn');
|
||
|
||
const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
|
||
const checkUpdateBtn = panel.querySelector('#check-update-btn');
|
||
const updateStatusEl = panel.querySelector('#update-status');
|
||
|
||
let currentCodexConfig = null;
|
||
let codexEditingProfiles = [];
|
||
let codexActiveProfile = '';
|
||
let codexRetryConfig = { mode: 'limited', intervalSeconds: 2, maxAttempts: 3 };
|
||
let _onUpdateInfo = null;
|
||
|
||
function showCodexStatus(msg, type) {
|
||
codexStatus.textContent = msg;
|
||
codexStatus.className = 'settings-status ' + (type || '');
|
||
}
|
||
|
||
function normalizeCodexRetryConfig(raw = {}) {
|
||
const mode = ['off', 'limited', 'forever'].includes(raw.mode) ? raw.mode : 'limited';
|
||
const intervalSeconds = Math.max(1, Math.min(3600, Number.parseInt(String(raw.intervalSeconds || ''), 10) || 2));
|
||
const maxAttempts = Math.max(1, Math.min(1000, Number.parseInt(String(raw.maxAttempts || ''), 10) || 3));
|
||
return { mode, intervalSeconds, maxAttempts };
|
||
}
|
||
|
||
function syncCodexRetryInputs() {
|
||
const mode = codexRetryModeSelect.value;
|
||
const disabled = mode === 'off';
|
||
codexRetryIntervalInput.disabled = disabled;
|
||
codexRetryAttemptsInput.disabled = disabled || mode === 'forever';
|
||
codexRetryAttemptsField.classList.toggle('settings-field-disabled', disabled || mode === 'forever');
|
||
codexRetryNote.textContent = mode === 'off'
|
||
? '已关闭自动重试;容量/过载失败会直接显示错误。'
|
||
: mode === 'forever'
|
||
? '会一直按固定间隔重试;仍只在没有文本或工具调用时触发。'
|
||
: '按指定次数重试;仍只在没有文本或工具调用时触发,避免重复执行副作用。';
|
||
}
|
||
|
||
function setCodexRetryConfig(config) {
|
||
codexRetryConfig = normalizeCodexRetryConfig(config);
|
||
codexRetryModeSelect.value = codexRetryConfig.mode;
|
||
codexRetryIntervalInput.value = String(codexRetryConfig.intervalSeconds);
|
||
codexRetryAttemptsInput.value = String(codexRetryConfig.maxAttempts);
|
||
syncCodexRetryInputs();
|
||
}
|
||
|
||
function readCodexRetryConfig() {
|
||
return normalizeCodexRetryConfig({
|
||
mode: codexRetryModeSelect.value,
|
||
intervalSeconds: codexRetryIntervalInput.value,
|
||
maxAttempts: codexRetryAttemptsInput.value,
|
||
});
|
||
}
|
||
|
||
function renderCodexProfileArea() {
|
||
const mode = codexModeSelect.value;
|
||
if (mode === 'local') {
|
||
codexProfileArea.innerHTML = `
|
||
<div class="settings-inline-note">
|
||
当前将直接复用本机 <code>codex</code> 的登录态与 <code>~/.codex/config.toml</code>。这适合你已经在终端里正常使用 Codex 的场景。
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
if (codexEditingProfiles.length === 0) {
|
||
codexProfileArea.innerHTML = `
|
||
<div class="settings-inline-note">
|
||
自定义模式适合接 OpenAI 兼容服务,例如你提到的第三方 API 入口。这里仅覆盖 <strong>API Key</strong> 和 <strong>API Base URL</strong>,不会让配置页随意改模型 ID。
|
||
</div>
|
||
<div class="settings-actions" style="margin-top:0">
|
||
<button class="btn-test" id="codex-profile-add-first">+ 新建 Profile</button>
|
||
</div>
|
||
`;
|
||
panel.querySelector('#codex-profile-add-first').addEventListener('click', () => openCodexProfileModal());
|
||
return;
|
||
}
|
||
|
||
const options = codexEditingProfiles.map((profile) =>
|
||
`<option value="${escapeHtml(profile.name)}" ${profile.name === codexActiveProfile ? 'selected' : ''}>${escapeHtml(profile.name)}</option>`
|
||
).join('');
|
||
const currentProfile = codexEditingProfiles.find((profile) => profile.name === codexActiveProfile) || codexEditingProfiles[0];
|
||
if (currentProfile && !codexActiveProfile) codexActiveProfile = currentProfile.name;
|
||
const summaryBase = currentProfile?.apiBase ? escapeHtml(currentProfile.apiBase) : '未设置 API Base URL';
|
||
|
||
codexProfileArea.innerHTML = `
|
||
<div class="settings-inline-note">
|
||
自定义模式会为 cc-web 生成独立的 Codex 运行配置,只覆盖当前激活 Profile 的 <strong>API Key</strong> 与 <strong>API Base URL</strong>,不去碰你平时终端里用的全局登录态。
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>激活 Profile</label>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
<select class="settings-select" id="codex-profile-select" style="flex:1">
|
||
${options}
|
||
<option value="__new__">+ 新建 Profile</option>
|
||
</select>
|
||
<button class="btn-test" id="codex-profile-edit" style="padding:4px 10px">编辑</button>
|
||
<button class="btn-test" id="codex-profile-del" title="删除" style="padding:4px 8px">删除</button>
|
||
</div>
|
||
</div>
|
||
<div class="settings-inline-note">
|
||
当前 Profile:<strong>${escapeHtml(currentProfile?.name || '未选择')}</strong><br>
|
||
API Base URL:<code>${summaryBase}</code>
|
||
</div>
|
||
`;
|
||
|
||
panel.querySelector('#codex-profile-select').addEventListener('change', (e) => {
|
||
if (e.target.value === '__new__') {
|
||
openCodexProfileModal();
|
||
return;
|
||
}
|
||
codexActiveProfile = e.target.value;
|
||
renderCodexProfileArea();
|
||
});
|
||
|
||
panel.querySelector('#codex-profile-edit').addEventListener('click', () => {
|
||
openCodexProfileModal(codexActiveProfile);
|
||
});
|
||
|
||
panel.querySelector('#codex-profile-del').addEventListener('click', () => {
|
||
if (!codexActiveProfile) return;
|
||
if (!confirm(`确认删除 Codex Profile「${codexActiveProfile}」?`)) return;
|
||
codexEditingProfiles = codexEditingProfiles.filter((profile) => profile.name !== codexActiveProfile);
|
||
codexActiveProfile = codexEditingProfiles[0]?.name || '';
|
||
renderCodexProfileArea();
|
||
});
|
||
}
|
||
|
||
function openCodexProfileModal(profileName = '') {
|
||
const current = profileName
|
||
? codexEditingProfiles.find((profile) => profile.name === profileName)
|
||
: null;
|
||
const draft = current || { name: '', apiKey: '', apiBase: '' };
|
||
|
||
const modalOverlay = document.createElement('div');
|
||
modalOverlay.className = 'settings-overlay';
|
||
modalOverlay.style.zIndex = '10001';
|
||
const modal = document.createElement('div');
|
||
modal.className = 'settings-panel';
|
||
modal.style.maxWidth = '460px';
|
||
modal.innerHTML = `
|
||
<div class="settings-header">
|
||
<h3>${current ? `编辑 Profile: ${escapeHtml(current.name)}` : '新建 Codex Profile'}</h3>
|
||
<button class="settings-close" id="codex-profile-modal-close">×</button>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Profile 名称</label>
|
||
<input type="text" id="codex-profile-name" placeholder="例如 OpenRouter Work" value="${escapeHtml(draft.name || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Key</label>
|
||
<input type="text" id="codex-profile-apikey" placeholder="sk-..." value="${escapeHtml(draft.apiKey || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Base URL</label>
|
||
<input type="text" id="codex-profile-apibase" placeholder="https://api.openai.com/v1" value="${escapeHtml(draft.apiBase || '')}">
|
||
</div>
|
||
<div class="settings-inline-note">
|
||
这里不开放模型 ID 编辑。Codex 仍使用上方“默认模型”以及会话内的模型切换逻辑,只把 API 入口和密钥切换到当前 Profile。
|
||
</div>
|
||
<div class="settings-actions">
|
||
<button class="btn-save" id="codex-profile-ok">确定</button>
|
||
</div>
|
||
`;
|
||
modalOverlay.appendChild(modal);
|
||
document.body.appendChild(modalOverlay);
|
||
|
||
const closeModal = () => document.body.removeChild(modalOverlay);
|
||
modal.querySelector('#codex-profile-modal-close').addEventListener('click', closeModal);
|
||
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
|
||
|
||
modal.querySelector('#codex-profile-ok').addEventListener('click', () => {
|
||
const name = modal.querySelector('#codex-profile-name').value.trim();
|
||
const apiKey = modal.querySelector('#codex-profile-apikey').value.trim();
|
||
const apiBase = modal.querySelector('#codex-profile-apibase').value.trim();
|
||
if (!name) {
|
||
alert('请填写 Profile 名称');
|
||
return;
|
||
}
|
||
if (!apiKey) {
|
||
alert('请填写 API Key');
|
||
return;
|
||
}
|
||
if (!apiBase) {
|
||
alert('请填写 API Base URL');
|
||
return;
|
||
}
|
||
const existing = codexEditingProfiles.find((profile) => profile.name === name);
|
||
if (existing && existing !== current) {
|
||
alert('Profile 名称已存在');
|
||
return;
|
||
}
|
||
if (current) {
|
||
current.name = name;
|
||
current.apiKey = apiKey;
|
||
current.apiBase = apiBase;
|
||
} else {
|
||
codexEditingProfiles.push({ name, apiKey, apiBase });
|
||
}
|
||
codexActiveProfile = name;
|
||
closeModal();
|
||
renderCodexProfileArea();
|
||
});
|
||
}
|
||
|
||
_onCodexConfig = (config) => {
|
||
currentCodexConfig = config || {};
|
||
codexModeSelect.value = currentCodexConfig.mode || 'local';
|
||
codexEditingProfiles = (currentCodexConfig.profiles || []).map((profile) => ({ ...profile }));
|
||
codexActiveProfile = currentCodexConfig.activeProfile || (codexEditingProfiles[0]?.name || '');
|
||
setCodexRetryConfig(currentCodexConfig.retry || codexRetryConfig);
|
||
renderCodexProfileArea();
|
||
};
|
||
|
||
codexModeSelect.addEventListener('change', renderCodexProfileArea);
|
||
codexRetryModeSelect.addEventListener('change', syncCodexRetryInputs);
|
||
setCodexRetryConfig(codexRetryConfig);
|
||
|
||
codexSaveBtn.addEventListener('click', () => {
|
||
if (codexModeSelect.value === 'custom' && codexEditingProfiles.length === 0) {
|
||
showCodexStatus('自定义模式至少需要一个 Codex Profile', 'error');
|
||
return;
|
||
}
|
||
const config = {
|
||
mode: codexModeSelect.value,
|
||
activeProfile: codexActiveProfile,
|
||
profiles: codexEditingProfiles,
|
||
enableSearch: false,
|
||
retry: readCodexRetryConfig(),
|
||
};
|
||
send({ type: 'save_codex_config', config });
|
||
showCodexStatus('已保存', 'success');
|
||
});
|
||
|
||
pwOpenModalBtn.addEventListener('click', openPasswordModal);
|
||
|
||
checkUpdateBtn.addEventListener('click', () => {
|
||
updateStatusEl.textContent = '正在检查...';
|
||
updateStatusEl.className = 'settings-status';
|
||
_onUpdateInfo = (info) => {
|
||
_onUpdateInfo = null;
|
||
if (info.error) {
|
||
updateStatusEl.textContent = '检查失败: ' + info.error;
|
||
updateStatusEl.className = 'settings-status error';
|
||
return;
|
||
}
|
||
if (info.hasUpdate) {
|
||
updateStatusEl.innerHTML = `有新版本 <strong>v${escapeHtml(info.latestVersion)}</strong>(当前 v${escapeHtml(info.localVersion)}) <a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
} else {
|
||
updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
}
|
||
};
|
||
send({ type: 'check_update' });
|
||
});
|
||
|
||
window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };
|
||
|
||
closeBtn.addEventListener('click', hideSettingsPanel);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });
|
||
document.addEventListener('keydown', _settingsEscape);
|
||
}
|
||
|
||
function showSettingsPanel() {
|
||
if (isCodexLikeAgent(currentAgent)) {
|
||
showCodexSettingsPanel();
|
||
return;
|
||
}
|
||
// Request current configs (notify config is loaded on demand inside subpage)
|
||
send({ type: 'get_model_config' });
|
||
send({ type: 'get_notify_config' });
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay';
|
||
overlay.id = 'settings-overlay';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'settings-panel';
|
||
|
||
panel.innerHTML = `
|
||
<h3>
|
||
⚙ Claude 设置
|
||
<button class="settings-close" title="关闭">×</button>
|
||
</h3>
|
||
|
||
<div class="settings-section-title">Claude 配置</div>
|
||
<div class="settings-field">
|
||
<label>配置模式</label>
|
||
<select class="settings-select" id="model-mode">
|
||
<option value="local">读取本地配置文件 (~/.claude.json)</option>
|
||
<option value="custom">自定义配置</option>
|
||
</select>
|
||
</div>
|
||
<div id="model-custom-area"></div>
|
||
<div class="settings-actions" id="model-actions" style="display:none">
|
||
<button class="btn-save" id="model-save-btn">保存模型配置</button>
|
||
</div>
|
||
<div class="settings-status" id="model-status"></div>
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
${buildAppearanceSettingsHtml()}
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
${buildNotifyEntryHtml(null)}
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
<div class="settings-section-title">系统</div>
|
||
<div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
|
||
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
||
<button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
|
||
</div>
|
||
<div class="settings-status" id="update-status" style="margin-top:8px"></div>
|
||
`;
|
||
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
mountAppearanceSettings(panel);
|
||
const notifyPageBtn2 = panel.querySelector('[data-open-notify-page]');
|
||
if (notifyPageBtn2) notifyPageBtn2.addEventListener('click', openNotifySubpage);
|
||
|
||
// === Model Config UI ===
|
||
const modelModeSelect = panel.querySelector('#model-mode');
|
||
const modelCustomArea = panel.querySelector('#model-custom-area');
|
||
const modelActionsDiv = panel.querySelector('#model-actions');
|
||
const modelSaveBtn = panel.querySelector('#model-save-btn');
|
||
const modelStatusDiv = panel.querySelector('#model-status');
|
||
|
||
let modelCurrentConfig = null;
|
||
let modelEditingTemplates = [];
|
||
let modelActiveTemplate = '';
|
||
|
||
function showModelStatus(msg, type) {
|
||
modelStatusDiv.textContent = msg;
|
||
modelStatusDiv.className = 'settings-status ' + (type || '');
|
||
}
|
||
|
||
function renderModelCustomArea() {
|
||
if (modelModeSelect.value === 'local') {
|
||
modelCustomArea.innerHTML = `<div class="settings-field" style="color:var(--text-warning, #e8a838);font-size:0.85em">⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。</div>`;
|
||
modelActionsDiv.style.display = 'flex';
|
||
} else {
|
||
renderModelTemplateEditor();
|
||
modelActionsDiv.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function renderModelTemplateEditor() {
|
||
const activeName = modelActiveTemplate;
|
||
const tpl = modelEditingTemplates.find(t => t.name === activeName) || null;
|
||
const tplOptions = modelEditingTemplates.map(t =>
|
||
`<option value="${escapeHtml(t.name)}" ${t.name === activeName ? 'selected' : ''}>${escapeHtml(t.name)}</option>`
|
||
).join('');
|
||
|
||
if (modelEditingTemplates.length === 0) {
|
||
modelCustomArea.innerHTML = `
|
||
<div class="settings-field" style="color:var(--text-secondary);font-size:0.85em">尚无模板,点击下方按钮新建。</div>
|
||
<div class="settings-actions" style="margin-top:0">
|
||
<button class="btn-test" id="model-tpl-add-first">+ 新建模板</button>
|
||
</div>
|
||
`;
|
||
panel.querySelector('#model-tpl-add-first').addEventListener('click', () => {
|
||
const newName = prompt('输入新模板名称:');
|
||
if (!newName || !newName.trim()) return;
|
||
const n = newName.trim();
|
||
modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' });
|
||
modelActiveTemplate = n;
|
||
renderModelTemplateEditor();
|
||
});
|
||
return;
|
||
}
|
||
|
||
modelCustomArea.innerHTML = `
|
||
<div class="settings-field">
|
||
<label>激活模板</label>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
<select class="settings-select" id="model-tpl-select" style="flex:1">
|
||
${tplOptions}
|
||
<option value="__new__">+ 新建模板</option>
|
||
</select>
|
||
<button class="btn-test" id="model-tpl-edit" style="padding:4px 10px">编辑</button>
|
||
<button class="btn-test" id="model-tpl-del" title="删除" style="padding:4px 8px">删除</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
panel.querySelector('#model-tpl-select').addEventListener('change', (e) => {
|
||
if (e.target.value === '__new__') {
|
||
const newName = prompt('输入新模板名称:');
|
||
if (!newName || !newName.trim()) { e.target.value = modelActiveTemplate; return; }
|
||
const n = newName.trim();
|
||
if (modelEditingTemplates.find(t => t.name === n)) { alert('模板名称已存在'); e.target.value = modelActiveTemplate; return; }
|
||
modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' });
|
||
modelActiveTemplate = n;
|
||
renderModelTemplateEditor();
|
||
openTplEditModal();
|
||
} else {
|
||
modelActiveTemplate = e.target.value;
|
||
renderModelTemplateEditor();
|
||
}
|
||
});
|
||
|
||
panel.querySelector('#model-tpl-edit').addEventListener('click', () => {
|
||
openTplEditModal();
|
||
});
|
||
|
||
const delBtn = panel.querySelector('#model-tpl-del');
|
||
if (delBtn) {
|
||
delBtn.addEventListener('click', () => {
|
||
if (!modelActiveTemplate) return;
|
||
if (!confirm(`确认删除模板「${modelActiveTemplate}」?`)) return;
|
||
modelEditingTemplates = modelEditingTemplates.filter(t => t.name !== modelActiveTemplate);
|
||
modelActiveTemplate = modelEditingTemplates[0]?.name || '';
|
||
renderModelTemplateEditor();
|
||
});
|
||
}
|
||
}
|
||
|
||
function openTplEditModal() {
|
||
const tpl = modelEditingTemplates.find(t => t.name === modelActiveTemplate);
|
||
if (!tpl) return;
|
||
|
||
const modalOverlay = document.createElement('div');
|
||
modalOverlay.className = 'settings-overlay';
|
||
modalOverlay.style.zIndex = '10001';
|
||
const modal = document.createElement('div');
|
||
modal.className = 'settings-panel';
|
||
modal.style.maxWidth = '460px';
|
||
modal.innerHTML = `
|
||
<div class="settings-header">
|
||
<h3>编辑模板: ${escapeHtml(tpl.name)}</h3>
|
||
<button class="settings-close" id="tpl-modal-close">×</button>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>模板名称</label>
|
||
<input type="text" id="tpl-ed-name" value="${escapeHtml(tpl.name)}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Key</label>
|
||
<input type="text" id="tpl-ed-apikey" placeholder="sk-ant-..." value="${escapeHtml(tpl.apiKey || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Base URL</label>
|
||
<input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
|
||
</div>
|
||
|
||
<div class="settings-divider" style="margin:12px 0"></div>
|
||
|
||
<div class="settings-field">
|
||
<label style="display:flex;align-items:center;gap:8px;font-weight:600">
|
||
获取上游模型列表
|
||
</label>
|
||
<div style="display:flex;gap:6px;align-items:center;margin-top:4px">
|
||
<label style="font-size:0.85em;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||
<input type="checkbox" id="tpl-ed-custom-endpoint"> 端点
|
||
</label>
|
||
<input type="text" id="tpl-ed-models-endpoint" placeholder="/v1/models" style="flex:1;display:none" value="">
|
||
</div>
|
||
<div style="display:flex;gap:6px;margin-top:6px;align-items:center">
|
||
<button class="btn-test" id="tpl-ed-fetch-models" style="padding:4px 12px;white-space:nowrap">获取模型</button>
|
||
<span id="tpl-ed-fetch-status" style="font-size:0.85em;color:var(--text-secondary)"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-divider" style="margin:12px 0"></div>
|
||
|
||
<div class="settings-field">
|
||
<label>默认模型 (ANTHROPIC_MODEL)</label>
|
||
<input type="text" id="tpl-ed-default" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}" autocomplete="off">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Opus 模型名</label>
|
||
<input type="text" id="tpl-ed-opus" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}" autocomplete="off">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Sonnet 模型名</label>
|
||
<input type="text" id="tpl-ed-sonnet" list="tpl-dl-models" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}" autocomplete="off">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Haiku 模型名</label>
|
||
<input type="text" id="tpl-ed-haiku" list="tpl-dl-models" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}" autocomplete="off">
|
||
</div>
|
||
<datalist id="tpl-dl-models"></datalist>
|
||
<div class="settings-actions">
|
||
<button class="btn-save" id="tpl-ed-ok">确定</button>
|
||
</div>
|
||
`;
|
||
modalOverlay.appendChild(modal);
|
||
document.body.appendChild(modalOverlay);
|
||
|
||
// Custom endpoint checkbox toggle
|
||
const customEndpointCb = modal.querySelector('#tpl-ed-custom-endpoint');
|
||
const endpointInput = modal.querySelector('#tpl-ed-models-endpoint');
|
||
customEndpointCb.addEventListener('change', () => {
|
||
endpointInput.style.display = customEndpointCb.checked ? '' : 'none';
|
||
});
|
||
|
||
// Fetch models
|
||
const fetchBtn = modal.querySelector('#tpl-ed-fetch-models');
|
||
const fetchStatus = modal.querySelector('#tpl-ed-fetch-status');
|
||
const datalist = modal.querySelector('#tpl-dl-models');
|
||
|
||
fetchBtn.addEventListener('click', () => {
|
||
const apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
|
||
const apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
|
||
if (!apiBase || !apiKey) {
|
||
fetchStatus.textContent = '请先填写 API Base 和 API Key';
|
||
fetchStatus.style.color = 'var(--text-error, #e85d5d)';
|
||
return;
|
||
}
|
||
const modelsEndpoint = customEndpointCb.checked ? endpointInput.value.trim() : '';
|
||
fetchBtn.disabled = true;
|
||
fetchStatus.textContent = '正在获取...';
|
||
fetchStatus.style.color = 'var(--text-secondary)';
|
||
|
||
_onFetchModelsResult = (result) => {
|
||
_onFetchModelsResult = null;
|
||
fetchBtn.disabled = false;
|
||
if (result.success) {
|
||
datalist.innerHTML = result.models.map(m => `<option value="${escapeHtml(m)}">`).join('');
|
||
fetchStatus.textContent = `获取到 ${result.models.length} 个模型`;
|
||
fetchStatus.style.color = 'var(--text-success, #5dbe5d)';
|
||
} else {
|
||
fetchStatus.textContent = result.message || '获取失败';
|
||
fetchStatus.style.color = 'var(--text-error, #e85d5d)';
|
||
}
|
||
};
|
||
|
||
send({ type: 'fetch_models', apiBase, apiKey, modelsEndpoint: modelsEndpoint || undefined, templateName: tpl.name });
|
||
});
|
||
|
||
const closeModal = () => {
|
||
_onFetchModelsResult = null;
|
||
document.body.removeChild(modalOverlay);
|
||
};
|
||
modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal);
|
||
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
|
||
|
||
modal.querySelector('#tpl-ed-ok').addEventListener('click', () => {
|
||
const newName = modal.querySelector('#tpl-ed-name').value.trim();
|
||
if (newName && newName !== tpl.name) {
|
||
if (modelEditingTemplates.find(t => t.name === newName && t !== tpl)) { alert('模板名称已存在'); return; }
|
||
tpl.name = newName;
|
||
modelActiveTemplate = newName;
|
||
}
|
||
tpl.apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
|
||
tpl.apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
|
||
tpl.defaultModel = modal.querySelector('#tpl-ed-default').value.trim();
|
||
tpl.opusModel = modal.querySelector('#tpl-ed-opus').value.trim();
|
||
tpl.sonnetModel = modal.querySelector('#tpl-ed-sonnet').value.trim();
|
||
tpl.haikuModel = modal.querySelector('#tpl-ed-haiku').value.trim();
|
||
closeModal();
|
||
renderModelTemplateEditor();
|
||
});
|
||
}
|
||
|
||
function saveTplFields() {
|
||
// Fields are now saved via modal, no inline fields to read
|
||
}
|
||
|
||
modelModeSelect.addEventListener('change', renderModelCustomArea);
|
||
|
||
modelSaveBtn.addEventListener('click', () => {
|
||
if (modelModeSelect.value === 'custom') saveTplFields();
|
||
const config = {
|
||
mode: modelModeSelect.value,
|
||
activeTemplate: modelActiveTemplate,
|
||
templates: modelEditingTemplates,
|
||
};
|
||
send({ type: 'save_model_config', config });
|
||
showModelStatus('已保存', 'success');
|
||
});
|
||
|
||
_onModelConfig = (config) => {
|
||
modelCurrentConfig = config;
|
||
modelEditingTemplates = (config.templates || []).map(t => Object.assign({}, t));
|
||
modelActiveTemplate = config.activeTemplate || (modelEditingTemplates[0]?.name || '');
|
||
modelModeSelect.value = config.mode || 'local';
|
||
renderModelCustomArea();
|
||
};
|
||
|
||
// === Notify Config UI (moved to subpage) ===
|
||
// notify config is handled by openNotifySubpage()
|
||
|
||
const closeBtn = panel.querySelector('.settings-close');
|
||
const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
|
||
pwOpenModalBtn.addEventListener('click', openPasswordModal);
|
||
|
||
// Check update button
|
||
const checkUpdateBtn = panel.querySelector('#check-update-btn');
|
||
const updateStatusEl = panel.querySelector('#update-status');
|
||
let _onUpdateInfo = null;
|
||
checkUpdateBtn.addEventListener('click', () => {
|
||
updateStatusEl.textContent = '正在检查...';
|
||
updateStatusEl.className = 'settings-status';
|
||
_onUpdateInfo = (info) => {
|
||
_onUpdateInfo = null;
|
||
if (info.error) {
|
||
updateStatusEl.textContent = '检查失败: ' + info.error;
|
||
updateStatusEl.className = 'settings-status error';
|
||
return;
|
||
}
|
||
if (info.hasUpdate) {
|
||
updateStatusEl.innerHTML = `有新版本 <strong>v${escapeHtml(info.latestVersion)}</strong>(当前 v${escapeHtml(info.localVersion)}) <a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
} else {
|
||
updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
}
|
||
};
|
||
send({ type: 'check_update' });
|
||
});
|
||
|
||
// Wire _onUpdateInfo into WS handler via closure
|
||
const _origOnUpdateInfo = window._ccOnUpdateInfo;
|
||
window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };
|
||
|
||
closeBtn.addEventListener('click', hideSettingsPanel);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });
|
||
|
||
document.addEventListener('keydown', _settingsEscape);
|
||
}
|
||
|
||
function hideSettingsPanel() {
|
||
const overlay = document.getElementById('settings-overlay');
|
||
if (overlay) overlay.remove();
|
||
document.querySelectorAll('.settings-subpage-overlay').forEach((node) => node.remove());
|
||
_onNotifyConfig = null;
|
||
_onNotifyTestResult = null;
|
||
_onModelConfig = null;
|
||
_onCodexConfig = null;
|
||
_onFetchModelsResult = null;
|
||
window._ccOnUpdateInfo = null;
|
||
document.removeEventListener('keydown', _settingsEscape);
|
||
}
|
||
|
||
function _settingsEscape(e) {
|
||
if (e.key === 'Escape') hideSettingsPanel();
|
||
}
|
||
|
||
if (settingsBtn) {
|
||
settingsBtn.addEventListener('click', showSettingsPanel);
|
||
}
|
||
|
||
// --- Force Change Password ---
|
||
function showForceChangePassword() {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'force-change-overlay';
|
||
overlay.id = 'force-change-overlay';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'force-change-panel';
|
||
|
||
panel.innerHTML = `
|
||
<img class="login-logo" src="icon-192.png" alt="CC-Web">
|
||
<h2>修改初始密码</h2>
|
||
<p>首次登录需要设置新密码</p>
|
||
<div class="force-change-form">
|
||
<input type="password" id="fc-new-pw" placeholder="新密码" autocomplete="new-password">
|
||
<div class="password-hint" id="fc-hint">至少 8 位,包含大写/小写/数字/特殊字符中的 2 种</div>
|
||
<input type="password" id="fc-confirm-pw" placeholder="确认新密码" autocomplete="new-password">
|
||
<button id="fc-submit-btn" class="fc-submit-btn" disabled>确认修改</button>
|
||
<div class="fc-status" id="fc-status"></div>
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
|
||
const newPwInput = panel.querySelector('#fc-new-pw');
|
||
const confirmPwInput = panel.querySelector('#fc-confirm-pw');
|
||
const hintEl = panel.querySelector('#fc-hint');
|
||
const submitBtn = panel.querySelector('#fc-submit-btn');
|
||
const statusEl = panel.querySelector('#fc-status');
|
||
|
||
function checkStrength() {
|
||
const pw = newPwInput.value;
|
||
const confirm = confirmPwInput.value;
|
||
if (!pw) {
|
||
hintEl.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
|
||
hintEl.className = 'password-hint';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
const result = clientValidatePassword(pw);
|
||
if (!result.valid) {
|
||
hintEl.textContent = result.message;
|
||
hintEl.className = 'password-hint error';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
hintEl.textContent = '密码强度符合要求';
|
||
hintEl.className = 'password-hint success';
|
||
submitBtn.disabled = !confirm || confirm !== pw;
|
||
}
|
||
|
||
newPwInput.addEventListener('input', checkStrength);
|
||
confirmPwInput.addEventListener('input', checkStrength);
|
||
|
||
submitBtn.addEventListener('click', () => {
|
||
const newPw = newPwInput.value;
|
||
const confirmPw = confirmPwInput.value;
|
||
if (newPw !== confirmPw) {
|
||
statusEl.textContent = '两次密码不一致';
|
||
statusEl.className = 'fc-status error';
|
||
return;
|
||
}
|
||
submitBtn.disabled = true;
|
||
statusEl.textContent = '正在修改...';
|
||
statusEl.className = 'fc-status';
|
||
send({ type: 'change_password', currentPassword: loginPasswordValue || localStorage.getItem('cc-web-pw') || '', newPassword: newPw });
|
||
});
|
||
|
||
newPwInput.focus();
|
||
}
|
||
|
||
function hideForceChangePassword() {
|
||
const overlay = document.getElementById('force-change-overlay');
|
||
if (overlay) overlay.remove();
|
||
}
|
||
|
||
function clientValidatePassword(pw) {
|
||
if (!pw || pw.length < 8) {
|
||
return { valid: false, message: '密码长度至少 8 位' };
|
||
}
|
||
let types = 0;
|
||
if (/[a-z]/.test(pw)) types++;
|
||
if (/[A-Z]/.test(pw)) types++;
|
||
if (/[0-9]/.test(pw)) types++;
|
||
if (/[^a-zA-Z0-9]/.test(pw)) types++;
|
||
if (types < 2) {
|
||
return { valid: false, message: '需包含至少 2 种字符类型(大写/小写/数字/特殊字符)' };
|
||
}
|
||
return { valid: true, message: '' };
|
||
}
|
||
|
||
// --- Password Changed Handler ---
|
||
let _onPasswordChanged = null;
|
||
|
||
function handlePasswordChanged(msg) {
|
||
if (msg.success) {
|
||
// Update token
|
||
authToken = msg.token;
|
||
localStorage.setItem('cc-web-token', msg.token);
|
||
// Update remembered password
|
||
if (localStorage.getItem('cc-web-pw')) {
|
||
// Clear old remembered password since it's changed
|
||
localStorage.removeItem('cc-web-pw');
|
||
}
|
||
|
||
// If force-change overlay is open, close it and load sessions
|
||
const fcOverlay = document.getElementById('force-change-overlay');
|
||
if (fcOverlay) {
|
||
hideForceChangePassword();
|
||
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
|
||
showToast('密码修改成功');
|
||
}
|
||
|
||
// If settings panel change password
|
||
if (_onPasswordChanged) {
|
||
_onPasswordChanged({ success: true, message: msg.message });
|
||
_onPasswordChanged = null;
|
||
}
|
||
} else {
|
||
// Force-change error
|
||
const fcStatus = document.querySelector('#fc-status');
|
||
if (fcStatus) {
|
||
fcStatus.textContent = msg.message || '修改失败';
|
||
fcStatus.className = 'fc-status error';
|
||
const btn = document.querySelector('#fc-submit-btn');
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
// Settings panel error
|
||
if (_onPasswordChanged) {
|
||
_onPasswordChanged({ success: false, message: msg.message });
|
||
_onPasswordChanged = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Recent CWD memory (localStorage) ---
|
||
const RECENT_CWD_KEY = 'cc-web-recent-cwds';
|
||
const RECENT_CWD_MAX = 5;
|
||
|
||
function getRecentCwds() {
|
||
try {
|
||
const raw = localStorage.getItem(RECENT_CWD_KEY);
|
||
return raw ? JSON.parse(raw) : [];
|
||
} catch { return []; }
|
||
}
|
||
|
||
function saveRecentCwd(cwd) {
|
||
if (!cwd) return;
|
||
let list = getRecentCwds().filter(p => p !== cwd);
|
||
list.unshift(cwd);
|
||
if (list.length > RECENT_CWD_MAX) list = list.slice(0, RECENT_CWD_MAX);
|
||
try { localStorage.setItem(RECENT_CWD_KEY, JSON.stringify(list)); } catch {}
|
||
}
|
||
|
||
function branchFromAssistantMessage(messageIndex) {
|
||
if (!currentSessionId) {
|
||
appendError('当前没有可分支的会话。', { transient: true, autoDismissMs: 4000 });
|
||
return;
|
||
}
|
||
if (!Number.isFinite(messageIndex) || messageIndex < 0) {
|
||
appendError('无法定位分支消息,请刷新会话后重试。', { transient: true, autoDismissMs: 4000 });
|
||
return;
|
||
}
|
||
const sourceTitle = (chatTitle.textContent || '新会话').trim() || '新会话';
|
||
const cwd = currentCwd || getSessionEffectiveCwd(currentSessionId) || null;
|
||
requestNewSession({
|
||
cwd,
|
||
rawCwd: cwd || '',
|
||
agent: currentAgent,
|
||
mode: currentMode,
|
||
model: currentModel || '',
|
||
title: `${sourceTitle} 的分支`,
|
||
branchSourceSessionId: currentSessionId,
|
||
branchMessageIndex: messageIndex,
|
||
});
|
||
}
|
||
|
||
function requestNewSession(options = {}) {
|
||
const cwd = options.cwd || null;
|
||
const rawCwd = options.rawCwd !== undefined ? options.rawCwd : (cwd || '');
|
||
const agent = normalizeAgent(options.agent || currentAgent);
|
||
const mode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode;
|
||
const model = typeof options.model === 'string' ? options.model.trim() : '';
|
||
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
||
const branchSourceSessionId = String(options.branchSourceSessionId || '').trim();
|
||
const branchMessageIndex = Number.isFinite(Number(options.branchMessageIndex))
|
||
? Number(options.branchMessageIndex)
|
||
: null;
|
||
const requestId = createSessionSwitchRequestId('new');
|
||
pendingNewSessionRequest = {
|
||
cwd,
|
||
rawCwd,
|
||
agent,
|
||
mode,
|
||
model,
|
||
title,
|
||
branchSourceSessionId,
|
||
branchMessageIndex,
|
||
requestId,
|
||
};
|
||
if (cwd) saveRecentCwd(cwd);
|
||
const payload = { type: 'new_session', cwd, agent, mode, requestId };
|
||
if (model) payload.model = model;
|
||
if (title) payload.title = title;
|
||
if (branchSourceSessionId) payload.branchSourceSessionId = branchSourceSessionId;
|
||
if (branchMessageIndex !== null) payload.branchMessageIndex = branchMessageIndex;
|
||
send(payload);
|
||
}
|
||
|
||
// --- New Session Modal ---
|
||
let _onCwdSuggestions = null;
|
||
|
||
function showNewSessionModal(options = {}) {
|
||
const targetAgent = normalizeAgent(options.agent || currentAgent);
|
||
const targetLabel = AGENT_LABELS[targetAgent] || AGENT_LABELS.claude;
|
||
const recentCwds = getRecentCwds();
|
||
const requestedMode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode;
|
||
let suggestionsRequested = false;
|
||
let suggestionState = {
|
||
defaultPath: '',
|
||
paths: [],
|
||
};
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.id = 'new-session-overlay';
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel">
|
||
<div class="modal-header">
|
||
<span class="modal-title">新建 ${escapeHtml(targetLabel)} 会话</span>
|
||
<button class="modal-close-btn" id="ns-close-btn">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
${buildAgentContextCard(targetAgent, `当前将在 ${targetLabel} 区创建会话`, `新会话会直接进入 ${targetLabel} 模块,并只出现在 ${targetLabel} 会话列表中。`)}
|
||
<div class="modal-stack">
|
||
<div>
|
||
<label class="modal-field-label" for="ns-cwd-input">工作目录</label>
|
||
<div class="modal-field-row">
|
||
<input type="text" id="ns-cwd-input" class="modal-text-input" placeholder="例如 /home/user/project" list="ns-cwd-list" autocomplete="off">
|
||
<button type="button" class="modal-btn-secondary modal-btn-inline" id="ns-pick-dir-btn">选择目录</button>
|
||
</div>
|
||
<div class="modal-field-hint" id="ns-cwd-tip">正在准备默认目录…</div>
|
||
<div class="modal-quick-picks" id="ns-cwd-picks"></div>
|
||
<datalist id="ns-cwd-list"></datalist>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-btn-secondary" id="ns-cancel-btn">取消</button>
|
||
<button class="modal-btn-primary" id="ns-create-btn">创建</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const cwdInput = overlay.querySelector('#ns-cwd-input');
|
||
const cwdList = overlay.querySelector('#ns-cwd-list');
|
||
const cwdTip = overlay.querySelector('#ns-cwd-tip');
|
||
const cwdPicks = overlay.querySelector('#ns-cwd-picks');
|
||
const createBtn = overlay.querySelector('#ns-create-btn');
|
||
const pickDirBtn = overlay.querySelector('#ns-pick-dir-btn');
|
||
|
||
cwdInput.value = String(options.cwd || recentCwds[0] || '').trim();
|
||
|
||
function getMergedCwdSuggestions() {
|
||
const seen = new Set();
|
||
const merged = [];
|
||
for (const candidate of [...recentCwds, suggestionState.defaultPath, ...(suggestionState.paths || [])]) {
|
||
const pathValue = String(candidate || '').trim();
|
||
if (!pathValue || seen.has(pathValue)) continue;
|
||
seen.add(pathValue);
|
||
merged.push(pathValue);
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function getEffectiveCwd() {
|
||
return cwdInput.value.trim() || suggestionState.defaultPath || null;
|
||
}
|
||
|
||
function renderCwdOptions() {
|
||
const merged = getMergedCwdSuggestions();
|
||
cwdList.innerHTML = merged
|
||
.map((pathValue, index) => `<option value="${escapeHtml(pathValue)}"${index < recentCwds.length ? ' label="最近"' : ''}></option>`)
|
||
.join('');
|
||
|
||
const quickPickPaths = merged.slice(0, 6);
|
||
cwdPicks.innerHTML = quickPickPaths.map((pathValue) => `
|
||
<button
|
||
type="button"
|
||
class="modal-quick-pick"
|
||
data-path="${escapeHtml(pathValue)}"
|
||
title="${escapeHtml(pathValue)}"
|
||
>${escapeHtml(pathValue)}</button>
|
||
`).join('');
|
||
|
||
cwdPicks.querySelectorAll('.modal-quick-pick').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const pathValue = button.dataset.path || '';
|
||
if (!pathValue) return;
|
||
cwdInput.value = pathValue;
|
||
cwdInput.focus();
|
||
});
|
||
});
|
||
|
||
const fallbackPath = suggestionState.defaultPath || '';
|
||
cwdTip.textContent = fallbackPath
|
||
? `留空时默认使用 ${fallbackPath}`
|
||
: '可手动输入路径,也可以点按钮选择目录';
|
||
}
|
||
|
||
function requestCwdSuggestions() {
|
||
if (suggestionsRequested) return;
|
||
suggestionsRequested = true;
|
||
_onCwdSuggestions = (payload) => {
|
||
suggestionState = {
|
||
defaultPath: String(payload?.defaultPath || '').trim(),
|
||
paths: Array.isArray(payload?.paths) ? payload.paths : [],
|
||
};
|
||
if (!cwdInput.value.trim()) {
|
||
cwdInput.value = recentCwds[0] || suggestionState.defaultPath || '';
|
||
}
|
||
renderCwdOptions();
|
||
};
|
||
send({ type: 'list_cwd_suggestions' });
|
||
}
|
||
|
||
renderCwdOptions();
|
||
requestCwdSuggestions();
|
||
|
||
function createSession() {
|
||
const cwd = getEffectiveCwd();
|
||
const rawCwd = cwdInput.value.trim();
|
||
close();
|
||
requestNewSession({
|
||
cwd,
|
||
rawCwd,
|
||
agent: targetAgent,
|
||
mode: requestedMode,
|
||
model: options.model || '',
|
||
title: options.title || '',
|
||
branchSourceSessionId: options.branchSourceSessionId || '',
|
||
branchMessageIndex: options.branchMessageIndex,
|
||
});
|
||
}
|
||
|
||
pickDirBtn.addEventListener('click', () => {
|
||
showDirectoryPicker({
|
||
title: '选择工作目录',
|
||
initialPath: getEffectiveCwd() || '',
|
||
onChoose: (selectedPath) => {
|
||
cwdInput.value = selectedPath;
|
||
cwdInput.focus();
|
||
},
|
||
});
|
||
});
|
||
|
||
cwdInput.addEventListener('focus', () => {
|
||
if (!suggestionState.defaultPath && (!suggestionState.paths || suggestionState.paths.length === 0)) {
|
||
requestCwdSuggestions();
|
||
}
|
||
});
|
||
cwdInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
createSession();
|
||
}
|
||
});
|
||
|
||
function close() {
|
||
closeDirectoryPicker();
|
||
overlay.remove();
|
||
_onCwdSuggestions = null;
|
||
}
|
||
|
||
overlay.querySelector('#ns-close-btn').addEventListener('click', close);
|
||
overlay.querySelector('#ns-cancel-btn').addEventListener('click', close);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
createBtn.addEventListener('click', createSession);
|
||
|
||
cwdInput.focus();
|
||
}
|
||
|
||
function quickCreateProjectSession(cwd, options = {}) {
|
||
const targetCwd = String(cwd || '').trim();
|
||
requestNewSession({
|
||
cwd: targetCwd || null,
|
||
rawCwd: targetCwd,
|
||
agent: options.agent || currentAgent,
|
||
mode: options.mode || currentMode,
|
||
});
|
||
}
|
||
|
||
// --- Import Native Session Modal ---
|
||
let _onNativeSessions = null;
|
||
|
||
function appendImportVisibilityToggle(body, options) {
|
||
const hiddenCount = Number(options?.hiddenCount || 0);
|
||
if (!body || hiddenCount <= 0) return;
|
||
const row = document.createElement('label');
|
||
row.className = 'import-filter-row';
|
||
row.innerHTML = `
|
||
<span class="import-filter-copy">
|
||
<span class="import-filter-title">显示已导入会话</span>
|
||
<span class="import-filter-meta">已隐藏 ${hiddenCount} 个 cc-web 已存在的会话</span>
|
||
</span>
|
||
<span class="settings-switch">
|
||
<input type="checkbox" ${options.showImported ? 'checked' : ''}>
|
||
<span class="settings-switch-track" aria-hidden="true">
|
||
<span class="settings-switch-thumb"></span>
|
||
</span>
|
||
</span>
|
||
`;
|
||
const input = row.querySelector('input');
|
||
input.addEventListener('change', () => options.onToggle(!!input.checked));
|
||
body.appendChild(row);
|
||
}
|
||
|
||
function showImportSessionModal() {
|
||
if (currentAgent !== 'claude') return;
|
||
let nativeGroups = [];
|
||
let showImported = false;
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.id = 'import-session-overlay';
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel modal-panel-wide">
|
||
<div class="modal-header">
|
||
<span class="modal-title">导入本地 CLI 会话</span>
|
||
<button class="modal-close-btn" id="is-close-btn">✕</button>
|
||
</div>
|
||
<div class="modal-body" id="is-body">
|
||
${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}
|
||
<div class="modal-loading">正在加载…</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
function close() {
|
||
overlay.remove();
|
||
_onNativeSessions = null;
|
||
}
|
||
|
||
overlay.querySelector('#is-close-btn').addEventListener('click', close);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
|
||
function renderNativeSessions() {
|
||
const body = overlay.querySelector('#is-body');
|
||
if (!body) return;
|
||
if (!nativeGroups || nativeGroups.length === 0) {
|
||
body.innerHTML = `${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}<div class="modal-empty">未找到本地 CLI 会话</div>`;
|
||
return;
|
||
}
|
||
body.innerHTML = buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。');
|
||
const hiddenCount = nativeGroups.reduce((sum, group) => (
|
||
sum + (Array.isArray(group.sessions) ? group.sessions.filter((sess) => sess.alreadyImported).length : 0)
|
||
), 0);
|
||
appendImportVisibilityToggle(body, {
|
||
hiddenCount,
|
||
showImported,
|
||
onToggle: (next) => {
|
||
showImported = next;
|
||
renderNativeSessions();
|
||
},
|
||
});
|
||
|
||
const visibleGroups = nativeGroups
|
||
.map((group) => ({
|
||
...group,
|
||
sessions: showImported
|
||
? (group.sessions || [])
|
||
: (group.sessions || []).filter((sess) => !sess.alreadyImported),
|
||
}))
|
||
.filter((group) => group.sessions.length > 0);
|
||
if (visibleGroups.length === 0) {
|
||
body.insertAdjacentHTML('beforeend', `<div class="modal-empty">已隐藏 ${hiddenCount} 个已导入会话,打开上方开关可查看。</div>`);
|
||
return;
|
||
}
|
||
|
||
for (const group of visibleGroups) {
|
||
const groupEl = document.createElement('div');
|
||
groupEl.className = 'import-group';
|
||
// Convert slug dir to readable path
|
||
let readablePath = group.dir.replace(/-/g, '/');
|
||
if (!readablePath.startsWith('/')) readablePath = '/' + readablePath;
|
||
readablePath = readablePath.replace(/\/+/g, '/');
|
||
const groupTitle = document.createElement('div');
|
||
groupTitle.className = 'import-group-title';
|
||
groupTitle.textContent = readablePath;
|
||
groupEl.appendChild(groupTitle);
|
||
for (const sess of group.sessions) {
|
||
const item = document.createElement('div');
|
||
item.className = 'import-item';
|
||
const info = document.createElement('div');
|
||
info.className = 'import-item-info';
|
||
const titleEl = document.createElement('div');
|
||
titleEl.className = 'import-item-title';
|
||
titleEl.textContent = sess.title;
|
||
const meta = document.createElement('div');
|
||
meta.className = 'import-item-meta';
|
||
const cwdText = sess.cwd ? sess.cwd : '';
|
||
const timeText = sess.updatedAt ? timeAgo(sess.updatedAt) : '';
|
||
meta.textContent = [cwdText, timeText].filter(Boolean).join(' · ');
|
||
info.appendChild(titleEl);
|
||
info.appendChild(meta);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'import-item-btn';
|
||
btn.textContent = sess.alreadyImported ? '重新导入' : '导入';
|
||
btn.addEventListener('click', () => {
|
||
if (sess.alreadyImported) {
|
||
if (!confirm('已导入过此会话,重新导入将覆盖已有内容。确认继续?')) return;
|
||
} else {
|
||
if (!confirm('由于 cc-web 与本地 CLI 的逻辑不同,导入会话需要解析后方可展示,导入后将覆盖已有内容。确认继续?')) return;
|
||
}
|
||
close();
|
||
send({ type: 'import_native_session', sessionId: sess.sessionId, projectDir: group.dir });
|
||
});
|
||
item.appendChild(info);
|
||
item.appendChild(btn);
|
||
groupEl.appendChild(item);
|
||
}
|
||
body.appendChild(groupEl);
|
||
}
|
||
}
|
||
|
||
_onNativeSessions = (groups) => {
|
||
nativeGroups = Array.isArray(groups) ? groups : [];
|
||
renderNativeSessions();
|
||
};
|
||
|
||
send({ type: 'list_native_sessions' });
|
||
}
|
||
|
||
function showImportCodexSessionModal() {
|
||
if (!isCodexLikeAgent(currentAgent)) return;
|
||
const importAgent = currentAgent;
|
||
let codexItems = [];
|
||
let showImported = false;
|
||
const label = AGENT_LABELS[importAgent] || 'Codex';
|
||
const contextTitle = importAgent === 'codexapp'
|
||
? '从 Codex App rollout 历史导入'
|
||
: '从 Codex rollout 历史导入';
|
||
const contextCopy = importAgent === 'codexapp'
|
||
? '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复对话文本、工具调用和 token 统计,并绑定 Codex App 线程用于后续续接。'
|
||
: '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。';
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.id = 'import-codex-session-overlay';
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal-panel modal-panel-wide">
|
||
<div class="modal-header">
|
||
<span class="modal-title">导入本地 ${escapeHtml(label)} 会话</span>
|
||
<button class="modal-close-btn" id="ics-close-btn">✕</button>
|
||
</div>
|
||
<div class="modal-body" id="ics-body">
|
||
${buildAgentContextCard(importAgent, contextTitle, contextCopy)}
|
||
<div class="modal-loading">正在加载 ${escapeHtml(label)} 本地历史…</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
function close() {
|
||
overlay.remove();
|
||
_onCodexSessions = null;
|
||
}
|
||
|
||
overlay.querySelector('#ics-close-btn').addEventListener('click', close);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
|
||
function renderCodexSessions() {
|
||
const body = overlay.querySelector('#ics-body');
|
||
if (!body) return;
|
||
if (!codexItems || codexItems.length === 0) {
|
||
body.innerHTML = `${buildAgentContextCard(importAgent, contextTitle, contextCopy)}<div class="modal-empty">未找到本地 ${escapeHtml(label)} 会话</div>`;
|
||
return;
|
||
}
|
||
|
||
body.innerHTML = buildAgentContextCard(importAgent, contextTitle, contextCopy);
|
||
const hiddenCount = codexItems.filter((sess) => sess.alreadyImported).length;
|
||
appendImportVisibilityToggle(body, {
|
||
hiddenCount,
|
||
showImported,
|
||
onToggle: (next) => {
|
||
showImported = next;
|
||
renderCodexSessions();
|
||
},
|
||
});
|
||
|
||
const visibleItems = showImported ? codexItems : codexItems.filter((sess) => !sess.alreadyImported);
|
||
if (visibleItems.length === 0) {
|
||
body.insertAdjacentHTML('beforeend', `<div class="modal-empty">已隐藏 ${hiddenCount} 个已导入会话,打开上方开关可查看。</div>`);
|
||
return;
|
||
}
|
||
|
||
visibleItems.forEach((sess) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'import-item';
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'import-item-info';
|
||
|
||
const titleEl = document.createElement('div');
|
||
titleEl.className = 'import-item-title';
|
||
titleEl.textContent = sess.title || sess.threadId;
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'import-item-meta';
|
||
meta.textContent = [
|
||
sess.cwd || '',
|
||
sess.source ? `source:${sess.source}` : '',
|
||
sess.updatedAt ? timeAgo(sess.updatedAt) : '',
|
||
].filter(Boolean).join(' · ');
|
||
|
||
const tags = document.createElement('div');
|
||
tags.className = 'import-item-tags';
|
||
if (sess.cliVersion) {
|
||
const ver = document.createElement('span');
|
||
ver.className = 'import-item-tag';
|
||
ver.textContent = `CLI ${sess.cliVersion}`;
|
||
tags.appendChild(ver);
|
||
}
|
||
if (sess.source) {
|
||
const source = document.createElement('span');
|
||
source.className = 'import-item-tag';
|
||
source.textContent = sess.source;
|
||
tags.appendChild(source);
|
||
}
|
||
if ((sess.duplicateCount || 0) > 1) {
|
||
const merged = document.createElement('span');
|
||
merged.className = 'import-item-tag';
|
||
merged.textContent = `合并 ${sess.duplicateCount} 条`;
|
||
tags.appendChild(merged);
|
||
}
|
||
|
||
info.appendChild(titleEl);
|
||
info.appendChild(meta);
|
||
if (tags.children.length > 0) info.appendChild(tags);
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'import-item-btn';
|
||
btn.textContent = sess.alreadyImported ? '重新导入' : '导入';
|
||
btn.addEventListener('click', () => {
|
||
const confirmed = sess.alreadyImported
|
||
? confirm(`已导入过此 ${label} 会话,重新导入将覆盖已有内容。确认继续?`)
|
||
: confirm(`将解析本地 ${label} rollout 历史并导入当前 Web 视图。确认继续?`);
|
||
if (!confirmed) return;
|
||
close();
|
||
send({ type: 'import_codex_session', agent: importAgent, threadId: sess.threadId, rolloutPath: sess.rolloutPath });
|
||
});
|
||
|
||
item.appendChild(info);
|
||
item.appendChild(btn);
|
||
body.appendChild(item);
|
||
});
|
||
}
|
||
|
||
_onCodexSessions = (items) => {
|
||
codexItems = Array.isArray(items) ? items : [];
|
||
renderCodexSessions();
|
||
};
|
||
|
||
send({ type: 'list_codex_sessions', agent: importAgent });
|
||
}
|
||
|
||
// --- Helpers ---
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function timeAgo(dateStr) {
|
||
if (!dateStr) return '';
|
||
const diff = Date.now() - new Date(dateStr).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return '刚刚';
|
||
if (mins < 60) return `${mins}分钟前`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `${hours}小时前`;
|
||
const days = Math.floor(hours / 24);
|
||
if (days < 30) return `${days}天前`;
|
||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||
}
|
||
|
||
// --- Init ---
|
||
applyTheme(currentTheme);
|
||
setCurrentAgent(currentAgent);
|
||
updateNoteModeUI();
|
||
renderSessionList();
|
||
connect();
|
||
window.addEventListener('resize', updateCwdBadge);
|
||
window.addEventListener('online', () => {
|
||
reconnectAttempts = 0;
|
||
connect();
|
||
});
|
||
window.addEventListener('offline', clearReconnectTimer);
|
||
window.addEventListener('pagehide', () => {
|
||
isPageUnloading = true;
|
||
clearReconnectTimer();
|
||
if (ws && ws.readyState <= 1) {
|
||
ws.close(1001, 'pagehide');
|
||
}
|
||
});
|
||
window.addEventListener('pageshow', () => {
|
||
isPageUnloading = false;
|
||
if (!ws || ws.readyState > 1) {
|
||
reconnectAttempts = 0;
|
||
connect();
|
||
}
|
||
});
|
||
|
||
// Register Service Worker for mobile push notifications
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {});
|
||
}
|
||
|
||
// Restore remembered password
|
||
const savedPw = localStorage.getItem('cc-web-pw');
|
||
if (savedPw) {
|
||
loginPassword.value = savedPw;
|
||
rememberPw.checked = true;
|
||
}
|
||
|
||
// Visibility change: re-sync state when user returns to tab (critical for mobile)
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (!ws || ws.readyState > 1) {
|
||
// WS is dead, force reconnect
|
||
reconnectAttempts = 0;
|
||
connect();
|
||
} else if (ws.readyState === 1 && currentSessionId) {
|
||
// Preserve active streaming UI when returning to foreground.
|
||
if (isGenerating || currentSessionRunning) {
|
||
send({ type: 'load_session', sessionId: currentSessionId });
|
||
} else {
|
||
beginSessionSwitch(currentSessionId, { blocking: false, force: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!authToken) {
|
||
loginOverlay.hidden = false;
|
||
app.hidden = true;
|
||
} else {
|
||
loginOverlay.hidden = true;
|
||
app.hidden = false;
|
||
}
|
||
})();
|