Files
cc-web/public/app.js
2026-06-22 18:22:53 +08:00

8455 lines
322 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// === CC-Web Frontend ===
(function () {
'use strict';
const ASSET_VERSION = '20260621-cross-reply-collapse-last-section-offset';
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 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_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}`,
'.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_COLLAPSE_VISIBLE_LIMIT = 5;
const OLD_SESSION_COLLAPSE_DAYS = 7;
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
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 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 currentSessionRunning = false;
let fileBrowserState = null;
let directoryPickerState = null;
let codexAppUserInputModal = null;
let codexAppApprovalModal = null;
let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
let noteMode = false;
let noteDraftSeq = 0;
let isReloadingMcp = false;
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 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 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 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 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 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 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 (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, sendNoteBtn);
bubble.append(meta, text, actions);
div.append(avatar, bubble);
return div;
}
function renderPendingNotes(options = {}) {
if (!pendingNotesTray) return;
pendingNotesTray.innerHTML = '';
const notes = getCurrentNotes(false);
if (!notes || notes.length === 0) {
pendingNotesTray.hidden = true;
if (options.updateScrollbar !== false) updateScrollbar();
return;
}
const frag = document.createDocumentFragment();
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
pendingNotesTray.appendChild(frag);
pendingNotesTray.hidden = false;
if (options.scrollIntoView !== false && options.scroll !== false) {
pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight;
}
if (options.updateScrollbar !== false) updateScrollbar();
}
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 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 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 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="关闭">&times;</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();
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 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;
bubble.appendChild(button);
}
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 || '';
return {
sessionId,
id: sessionId,
messages: cloneMessages(payload.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) : [],
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 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;
}
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');
}
async function reloadCurrentMcpServers() {
if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return;
isReloadingMcp = true;
updateReloadMcpButtonUI();
try {
await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, {
method: 'POST',
});
showToast('已请求重载 MCP');
} 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 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');
}
}
async function openFileBrowserFile(targetPath) {
if (!fileBrowserState) return;
const state = fileBrowserState;
const normalizedPath = normalizeBrowserPath(targetPath);
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)}`);
state.previewMetaEl.textContent = metaParts.join(' · ');
state.previewEmptyEl.hidden = true;
state.previewCodeEl.hidden = false;
state.previewCodeEl.textContent = data.content || '';
setFileBrowserStatus(`已打开 ${data.name || '文件'}`);
} 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 splitCollapsedOldSessions(regularSessions, pinnedCount, getCollapseKey = () => '') {
const totalCount = pinnedCount + regularSessions.length;
if (totalCount <= OLD_SESSION_COLLAPSE_VISIBLE_LIMIT) {
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
}
const nowMs = Date.now();
const visibleRegularLimit = Math.max(0, OLD_SESSION_COLLAPSE_VISIBLE_LIMIT - pinnedCount);
const visibleRegularSessions = [];
const hiddenOldSessions = [];
regularSessions.forEach((session, index) => {
const collapseKey = getCollapseKey(session);
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
const canCollapse = !isExpanded && index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
if (canCollapse && !shouldKeepVisible) {
hiddenOldSessions.push(session);
} else {
visibleRegularSessions.push(session);
}
});
return { visibleRegularSessions, hiddenOldSessions };
}
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} 条 7 天前会话`);
button.innerHTML = `
<span class="session-list-load-more-title">加载更多</span>
<span class="session-list-load-more-meta">${hiddenCount} 条 7 天前会话</span>
`;
button.addEventListener('click', () => {
if (collapseKey) expandedOldSessionGroups.add(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);
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;
return {
waitingOnChildren: !!(meta?.waitingOnChildren || cached?.waitingOnChildren),
pendingReplyCount: Number(meta?.pendingReplyCount ?? cached?.pendingReplyCount ?? 0),
readyReplyCount: Number(meta?.readyReplyCount ?? cached?.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();
}
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) {
if (isCodexAppAgent(currentAgent)) {
importSessionBtn.textContent = 'Codex App 暂不支持导入';
importSessionBtn.disabled = true;
} else {
importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 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();
closeFileBrowser();
currentSessionId = null;
loadedHistorySessionId = null;
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;
setLastSessionForAgent(snapshot.agent, currentSessionId);
chatTitle.textContent = snapshot.title || '新会话';
updateSessionIdBadge();
setCurrentAgent(snapshotAgent);
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
setCurrentSessionRunningState(snapshot.isRunning);
setStatsDisplay(snapshot);
closeUserOutlinePanel();
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 });
} 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();
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 setSessionLoading(sessionId, options = {}) {
const loading = !!sessionId;
const blocking = options.blocking !== false;
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null } : null;
const showOverlay = !!(loading && blocking);
document.body.classList.toggle('session-loading-active', showOverlay);
sessionLoadingOverlay.hidden = !showOverlay;
sessionLoadingOverlay.setAttribute('aria-hidden', showOverlay ? 'false' : 'true');
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 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) 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 || '',
};
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,
});
}
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId }));
}
function showCachedSession(sessionId) {
const snapshot = buildCachedSessionSnapshot(sessionId);
if (!snapshot) return false;
if (currentSessionId && currentSessionId !== sessionId) {
send({ type: 'detach_view' });
}
closeUserOutlinePanel();
clearSessionLoading();
touchSessionCache(sessionId);
applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
return true;
}
function openSession(sessionId, options = {}) {
if (!sessionId) return;
closeUserOutlinePanel();
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 _previewCodeMap = new Map();
let _previewCodeId = 0;
const renderer = new marked.Renderer();
renderer.code = function (code, language) {
const lang = (language || 'plaintext').toLowerCase();
let highlighted;
try {
if (hljs.getLanguage(lang)) {
highlighted = hljs.highlight(code, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(code).value;
}
} catch {
highlighted = escapeHtml(code);
}
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 ? (++_previewCodeId) : 0;
if (canPreview) _previewCodeMap.set(cid, code);
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 cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code')?.textContent;
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';
}
};
// --- 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; }
handleServerMessage(msg);
};
socket.onclose = () => {
if (ws !== socket) return;
ws = null;
wsAuthenticated = false;
if (activeSessionLoad?.sessionId && !isPageUnloading) {
pendingSessionSwitchRequest = {
sessionId: activeSessionLoad.sessionId,
blocking: activeSessionLoad.blocking,
label: sessionLoadingLabel?.textContent || '',
};
}
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) {
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 = true;
}
} 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;
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
} else if (currentSessionId && !getSessionMeta(currentSessionId)) {
resetChatView(currentAgent);
}
break;
case 'session_info':
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
const snapshot = normalizeSessionSnapshot(msg);
sessions = sessions.map((session) => (
session.id === snapshot.sessionId
? {
...session,
cwd: snapshot.cwd || session.cwd || '',
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '',
title: snapshot.title || session.title,
pinnedAt: snapshot.pinnedAt || session.pinnedAt || null,
isRunning: snapshot.isRunning,
waitingOnChildren: snapshot.waitingOnChildren,
pendingReplyCount: snapshot.pendingReplyCount,
readyReplyCount: snapshot.readyReplyCount,
waitingReplyCount: snapshot.waitingReplyCount,
failedReplyCount: snapshot.failedReplyCount,
}
: session
));
if (activeSessionLoad?.sessionId === msg.sessionId) {
activeSessionLoad.snapshot = snapshot;
}
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 (activeSessionLoad?.sessionId === msg.sessionId) {
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,
});
if (!msg.remaining) {
finalizeLoadedSession(msg.sessionId);
}
}
break;
case 'session_message':
if (msg.sessionId && msg.message) {
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 (msg.message.crossConversation?.replyToRequestId) {
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 (msg.sessionId === currentSessionId && msg.message) {
collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id));
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
messagesDiv.appendChild(buildMsgElement(msg.message));
scrollToBottom();
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
}
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 '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,
createCwd: true,
});
},
onCancel: () => {
showNewSessionModal({
agent: request.agent,
cwd: request.rawCwd || request.cwd,
mode: request.mode,
});
},
});
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();
// 不禁用输入框,允许用户继续输入(但无法发送)
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;
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');
syncAssistantLastSectionButton(streamEl);
}
if (sessionId) {
migratePendingNotesToSession(sessionId, currentAgent);
}
pendingText = '';
activeToolCalls.clear();
activeTodoCallTargets.clear();
toolGroupCount = 0;
hasGrouped = false;
renderPendingNotes();
}
// --- 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;
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; }
textDiv.innerHTML = renderMarkdown(pendingText);
}
syncAssistantLastSectionButton(streamEl);
scrollToBottom();
}
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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');
textDiv.innerHTML = renderMarkdown(before);
bubble.appendChild(textDiv);
}
bubble.appendChild(createTodoListElement(parsed));
if (after.trim()) {
const textDiv = document.createElement('div');
textDiv.innerHTML = renderMarkdown(after);
bubble.appendChild(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) {}
}
bubble.innerHTML = renderMarkdown(content);
return;
}
if (Array.isArray(content)) {
content.forEach(block => {
if (block.type === 'text') {
const textDiv = document.createElement('div');
textDiv.innerHTML = renderMarkdown(block.text || '');
bubble.appendChild(textDiv);
} else if (block.type === 'todo_list') {
bubble.appendChild(createTodoListElement(block));
}
});
return;
}
bubble.innerHTML = renderMarkdown(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) {
const el = createMsgElement(m.role, m.content, m.attachments || [], m);
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);
}
}
}
}
return el;
}
function renderMessages(messages, options = {}) {
renderEpoch++;
const epoch = renderEpoch;
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) => frag.appendChild(buildMsgElement(message)));
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]));
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]));
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;
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) => frag.appendChild(buildMsgElement(m)));
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;
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);
scrollToBottom();
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);
scrollToBottom();
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);
scrollToBottom();
}
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;
}
// --- 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);
const oldSessionCollapseKeysBySessionId = new Map();
projectGroups.forEach((group) => {
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
group.sessions.forEach((session) => {
oldSessionCollapseKeysBySessionId.set(session.id, oldSessionCollapseKey);
});
});
ungroupedSessions.forEach((session) => {
oldSessionCollapseKeysBySessionId.set(session.id, getUngroupedOldSessionCollapseKey());
});
const { visibleRegularSessions, hiddenOldSessions } = isSearchingSessions
? { visibleRegularSessions: regularSessions, hiddenOldSessions: [] }
: splitCollapsedOldSessions(
regularSessions,
pinnedSessions.length,
(session) => oldSessionCollapseKeysBySessionId.get(session.id) || ''
);
const hiddenOldSessionCountsByKey = new Map();
hiddenOldSessions.forEach((session) => {
const oldSessionCollapseKey = oldSessionCollapseKeysBySessionId.get(session.id);
if (!oldSessionCollapseKey) return;
hiddenOldSessionCountsByKey.set(
oldSessionCollapseKey,
(hiddenOldSessionCountsByKey.get(oldSessionCollapseKey) || 0) + 1
);
});
const visibleRegularSessionIds = new Set(visibleRegularSessions.map((session) => session.id));
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 visibleGroupSessions = group.sessions.filter((session) => visibleRegularSessionIds.has(session.id));
const hiddenGroupOldSessionCount = hiddenOldSessionCountsByKey.get(oldSessionCollapseKey) || 0;
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 (hiddenGroupOldSessionCount > 0) {
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupOldSessionCount, 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 visibleUngroupedSessions = ungroupedSessions.filter((session) => visibleRegularSessionIds.has(session.id));
const hiddenUngroupedOldSessionCount = hiddenOldSessionCountsByKey.get(ungroupedCollapseKey) || 0;
for (const s of visibleUngroupedSessions) {
sessionList.appendChild(createSessionListItem(s));
}
if (hiddenUngroupedOldSessionCount > 0) {
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedOldSessionCount, 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);
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);
}
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 (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 (isCodexAppAgent(currentAgent)) {
appendError('Codex App 模式暂不支持导入本地会话。');
} else if (currentAgent === 'codex') {
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();
}
});
sendBtn.addEventListener('click', sendMessage);
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}」任务完成`,
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: 'QQQmsg' },
];
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">&times;</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="关闭">&times;</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-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 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 _onUpdateInfo = null;
function showCodexStatus(msg, type) {
codexStatus.textContent = msg;
codexStatus.className = 'settings-status ' + (type || '');
}
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">&times;</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 || '');
renderCodexProfileArea();
};
codexModeSelect.addEventListener('change', renderCodexProfileArea);
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,
};
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)}&nbsp;<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="关闭">&times;</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">&times;</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)}&nbsp;<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 = `
<div class="login-logo">CC</div>
<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 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;
pendingNewSessionRequest = {
cwd,
rawCwd,
agent,
mode,
};
if (cwd) saveRecentCwd(cwd);
send({ type: 'new_session', cwd, agent, mode });
}
// --- 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,
});
}
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 showImportSessionModal() {
if (currentAgent !== 'claude') return;
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(); });
_onNativeSessions = (groups) => {
const body = overlay.querySelector('#is-body');
if (!body) return;
if (!groups || groups.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 侧续接上下文。');
for (const group of groups) {
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);
}
};
send({ type: 'list_native_sessions' });
}
function showImportCodexSessionModal() {
if (currentAgent !== 'codex') return;
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">导入本地 Codex 会话</span>
<button class="modal-close-btn" id="ics-close-btn">✕</button>
</div>
<div class="modal-body" id="ics-body">
${buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。')}
<div class="modal-loading">正在加载 Codex 本地历史…</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(); });
_onCodexSessions = (items) => {
const body = overlay.querySelector('#ics-body');
if (!body) return;
if (!items || items.length === 0) {
body.innerHTML = `${buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。')}<div class="modal-empty">未找到本地 Codex 会话</div>`;
return;
}
body.innerHTML = buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。');
items.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);
}
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('已导入过此 Codex 会话,重新导入将覆盖已有内容。确认继续?')
: confirm('将解析本地 Codex rollout 历史并导入当前 Web 视图。确认继续?');
if (!confirmed) return;
close();
send({ type: 'import_codex_session', threadId: sess.threadId, rolloutPath: sess.rolloutPath });
});
item.appendChild(info);
item.appendChild(btn);
body.appendChild(item);
});
};
send({ type: 'list_codex_sessions' });
}
// --- Helpers ---
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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;
}
})();