// === CC-Web Frontend ===
(function () {
'use strict';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100;
const SLASH_COMMANDS = [
{ cmd: '/clear', desc: '清除当前会话' },
{ cmd: '/model', desc: '查看/切换模型' },
{ cmd: '/mode', desc: '查看/切换权限模式' },
{ cmd: '/cost', desc: '查看会话费用' },
{ cmd: '/compact', desc: '压缩上下文' },
{ cmd: '/help', desc: '显示帮助' },
];
const MODE_LABELS = {
default: '默认',
plan: 'Plan',
yolo: 'YOLO',
};
const MODEL_OPTIONS = [
{ value: 'opus', label: 'Opus', desc: '最强大,适合复杂任务' },
{ value: 'sonnet', label: 'Sonnet', desc: '平衡性能与速度' },
{ value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' },
];
const MODE_PICKER_OPTIONS = [
{ value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' },
{ value: 'plan', label: 'Plan', desc: '执行前需确认计划' },
{ value: 'default', label: '默认', desc: '标准权限审批' },
];
// --- State ---
let ws = null;
let authToken = localStorage.getItem('cc-web-token');
let currentSessionId = null;
let sessions = [];
let isGenerating = false;
let reconnectAttempts = 0;
let reconnectTimer = null;
let pendingText = '';
let renderTimer = null;
let activeToolCalls = new Map();
let cmdMenuIndex = -1;
let currentMode = localStorage.getItem('cc-web-mode') || 'yolo';
let currentModel = 'opus';
let loginPasswordValue = ''; // store login password for force-change flow
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
// --- 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 sidebar = $('#sidebar');
const sidebarOverlay = $('#sidebar-overlay');
const menuBtn = $('#menu-btn');
const newChatBtn = $('#new-chat-btn');
const sessionList = $('#session-list');
const chatTitle = $('#chat-title');
const costDisplay = $('#cost-display');
const messagesDiv = $('#messages');
const msgInput = $('#msg-input');
const sendBtn = $('#send-btn');
const abortBtn = $('#abort-btn');
const cmdMenu = $('#cmd-menu');
const modeSelect = $('#mode-select');
// --- 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));
// --- marked config ---
const renderer = new marked.Renderer();
renderer.code = function (code, language) {
const lang = language || 'plaintext';
let highlighted;
try {
if (hljs.getLanguage(lang)) {
highlighted = hljs.highlight(code, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(code).value;
}
} catch {
highlighted = escapeHtml(code);
}
return `
`;
};
marked.setOptions({ renderer, breaks: true, gfm: true });
window.ccCopyCode = function (btn) {
const code = btn.closest('.code-block-wrapper').querySelector('code').textContent;
navigator.clipboard.writeText(code).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
};
// --- WebSocket ---
function connect() {
if (ws && ws.readyState <= 1) return;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
reconnectAttempts = 0;
if (authToken) send({ type: 'auth', token: authToken });
};
ws.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
handleServerMessage(msg);
};
ws.onclose = () => scheduleReconnect();
ws.onerror = () => {};
}
function send(data) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
}
function scheduleReconnect() {
if (reconnectTimer) return;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
// --- Server Message Handler ---
function handleServerMessage(msg) {
switch (msg.type) {
case 'auth_result':
if (msg.success) {
authToken = msg.token;
localStorage.setItem('cc-web-token', msg.token);
loginOverlay.hidden = true;
app.hidden = false;
// Check if must change password
if (msg.mustChangePassword) {
showForceChangePassword();
} else {
// Auto-load last viewed session
const lastSession = localStorage.getItem('cc-web-session');
if (lastSession) {
send({ type: 'load_session', sessionId: lastSession });
}
}
} else {
authToken = null;
localStorage.removeItem('cc-web-token');
loginOverlay.hidden = false;
app.hidden = true;
loginError.hidden = false;
}
break;
case 'session_list':
sessions = msg.sessions || [];
renderSessionList();
break;
case 'session_info':
// Reset generating state (will be re-set by resume_generating if process is active)
if (isGenerating) {
isGenerating = false;
sendBtn.hidden = false;
abortBtn.hidden = true;
pendingText = '';
activeToolCalls.clear();
}
currentSessionId = msg.sessionId;
localStorage.setItem('cc-web-session', currentSessionId);
chatTitle.textContent = msg.title || '新会话';
// 同步 session 的 mode(如有)
if (msg.mode && MODE_LABELS[msg.mode]) {
currentMode = msg.mode;
modeSelect.value = currentMode;
localStorage.setItem('cc-web-mode', currentMode);
}
// 同步 session 的 model(如有)
if (msg.model) {
currentModel = msg.model;
}
renderMessages(msg.messages || []);
highlightActiveSession();
closeSidebar();
// Show notification for sessions completed in background
if (msg.hasUnread) {
showToast('后台任务已完成', msg.sessionId);
}
break;
case 'session_renamed':
if (msg.sessionId === currentSessionId) {
chatTitle.textContent = msg.title;
}
break;
case 'text_delta':
if (!isGenerating) startGenerating();
pendingText += msg.text;
scheduleRender();
break;
case 'tool_start':
if (!isGenerating) startGenerating();
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, done: false });
appendToolCall(msg.toolUseId, msg.name, msg.input, false);
break;
case 'tool_end':
if (activeToolCalls.has(msg.toolUseId)) {
activeToolCalls.get(msg.toolUseId).done = true;
}
updateToolCall(msg.toolUseId, msg.result);
break;
case 'cost':
costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`;
break;
case 'done':
finishGenerating(msg.sessionId);
break;
case 'system_message':
appendSystemMessage(msg.message);
break;
case 'mode_changed':
if (msg.mode && MODE_LABELS[msg.mode]) {
currentMode = msg.mode;
modeSelect.value = currentMode;
localStorage.setItem('cc-web-mode', currentMode);
}
break;
case 'model_changed':
if (msg.model) {
currentModel = msg.model;
}
break;
case 'resume_generating':
// Server has an active process for this session — resume streaming
startGenerating();
pendingText = msg.text || '';
if (pendingText) flushRender();
if (msg.toolCalls && msg.toolCalls.length > 0) {
for (const tc of msg.toolCalls) {
activeToolCalls.set(tc.id, { name: tc.name, done: tc.done });
appendToolCall(tc.id, tc.name, tc.input, tc.done);
if (tc.done && tc.result) {
updateToolCall(tc.id, tc.result);
}
}
}
break;
case 'error':
appendError(msg.message);
if (isGenerating) finishGenerating();
break;
case 'notify_config':
if (typeof _onNotifyConfig === 'function') _onNotifyConfig(msg.config);
break;
case 'notify_test_result':
if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg);
break;
case 'model_config':
if (typeof _onModelConfig === 'function') _onModelConfig(msg.config);
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
send({ type: 'load_session', sessionId: msg.sessionId });
} else {
send({ type: 'list_sessions' });
}
break;
case 'password_changed':
handlePasswordChanged(msg);
break;
}
}
// --- Generating State ---
function startGenerating() {
isGenerating = true;
pendingText = '';
activeToolCalls.clear();
sendBtn.hidden = true;
abortBtn.hidden = false;
// 不禁用输入框,允许用户继续输入(但无法发送)
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
const msgEl = createMsgElement('assistant', '');
msgEl.id = 'streaming-msg';
// 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器
const bubble = msgEl.querySelector('.msg-bubble');
bubble.innerHTML = '';
const textDiv = document.createElement('div');
textDiv.className = 'msg-text';
textDiv.innerHTML = '
';
const toolsDiv = document.createElement('div');
toolsDiv.className = 'msg-tools';
bubble.appendChild(textDiv);
bubble.appendChild(toolsDiv);
messagesDiv.appendChild(msgEl);
scrollToBottom();
}
function finishGenerating(sessionId) {
isGenerating = false;
sendBtn.hidden = false;
abortBtn.hidden = true;
msgInput.focus();
if (pendingText) flushRender();
const typing = document.querySelector('.typing-indicator');
if (typing) typing.remove();
const streamEl = document.getElementById('streaming-msg');
if (streamEl) streamEl.removeAttribute('id');
if (sessionId) currentSessionId = sessionId;
pendingText = '';
activeToolCalls.clear();
}
// --- 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;
let textDiv = bubble.querySelector('.msg-text');
if (!textDiv) { textDiv = bubble; }
textDiv.innerHTML = renderMarkdown(pendingText);
scrollToBottom();
}
function renderMarkdown(text) {
if (!text) return '
';
try { return marked.parse(text); }
catch { return escapeHtml(text); }
}
function createMsgElement(role, content) {
const div = document.createElement('div');
div.className = `msg ${role}`;
if (role === 'system') {
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
bubble.textContent = content;
div.appendChild(bubble);
return div;
}
const avatar = document.createElement('div');
avatar.className = 'msg-avatar';
avatar.textContent = role === 'user' ? 'U' : 'C';
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
if (role === 'user') {
bubble.style.whiteSpace = 'pre-wrap';
bubble.textContent = content;
} else {
bubble.innerHTML = content ? renderMarkdown(content) : '
';
}
div.appendChild(avatar);
div.appendChild(bubble);
return div;
}
let renderEpoch = 0;
function buildMsgElement(m) {
const el = createMsgElement(m.role, m.content);
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
const bubble = el.querySelector('.msg-bubble');
for (const tc of m.toolCalls) {
const details = document.createElement('details');
details.className = 'tool-call';
details.dataset.toolName = tc.name || '';
if (tc.name === 'AskUserQuestion') details.open = true;
const summary = document.createElement('summary');
summary.innerHTML = ` ${escapeHtml(tc.name)}`;
details.appendChild(summary);
const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input);
details.appendChild(buildToolContentElement(tc.name, displayInput));
bubble.appendChild(details);
}
}
return el;
}
function renderMessages(messages) {
renderEpoch++;
const epoch = renderEpoch;
messagesDiv.innerHTML = '';
if (messages.length === 0) {
messagesDiv.innerHTML = '✿
欢迎使用 CC-Web
开始与 Claude Code 对话
';
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);
scrollToBottom();
// Render remaining batches asynchronously, prepending each
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 frag = document.createDocumentFragment();
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i]));
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
updateScrollbar();
}, delay);
}
}
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 opts = document.createElement('div');
opts.className = 'ask-question-options';
q.options.forEach((opt, i) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ask-option-item';
item.addEventListener('click', () => appendAskOptionToInput(q, opt));
const title = document.createElement('div');
title.className = 'ask-option-label';
title.textContent = `${i + 1}. ${opt.label || ''}`;
item.appendChild(title);
if (opt.description) {
const desc = document.createElement('div');
desc.className = 'ask-option-desc';
desc.textContent = opt.description;
item.appendChild(desc);
}
opts.appendChild(item);
});
card.appendChild(opts);
}
wrapper.appendChild(card);
});
return wrapper;
}
function buildToolContentElement(name, input) {
if (name === 'AskUserQuestion') {
const questions = extractAskUserQuestions(input);
if (questions.length > 0) {
return createAskUserQuestionView(questions);
}
}
const inputStr = typeof input === 'string' ? input : (input ? JSON.stringify(input, null, 2) : '');
const content = document.createElement('div');
content.className = 'tool-call-content';
content.textContent = inputStr;
return content;
}
function appendToolCall(toolUseId, name, input, done) {
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 details = document.createElement('details');
details.className = 'tool-call';
details.id = `tool-${toolUseId}`;
details.dataset.toolName = name || '';
if (name === 'AskUserQuestion') details.open = true;
const summary = document.createElement('summary');
summary.innerHTML = ` ${escapeHtml(name)}`;
details.appendChild(summary);
details.appendChild(buildToolContentElement(name, input));
toolsDiv.appendChild(details);
scrollToBottom();
}
function updateToolCall(toolUseId, result) {
const el = document.getElementById(`tool-${toolUseId}`);
if (!el) return;
const icon = el.querySelector('.tool-call-icon');
if (icon) { icon.classList.remove('running'); icon.classList.add('done'); }
if (result) {
if (el.dataset.toolName === 'AskUserQuestion') {
return;
}
const content = el.querySelector('.tool-call-content');
if (content) content.textContent = result;
}
}
function showDeleteConfirm(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 = `
删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?
`;
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) {
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
messagesDiv.appendChild(createMsgElement('system', message));
scrollToBottom();
}
function appendError(message) {
const div = document.createElement('div');
div.className = 'msg system';
div.innerHTML = `⚠ ${escapeHtml(message)}
`;
messagesDiv.appendChild(div);
scrollToBottom();
}
function scrollToBottom() {
requestAnimationFrame(() => {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
updateScrollbar();
});
}
// --- Custom Scrollbar ---
const scrollbarEl = document.getElementById('custom-scrollbar');
const thumbEl = document.getElementById('custom-scrollbar-thumb');
function updateScrollbar() {
if (!scrollbarEl || !thumbEl) return;
const { scrollTop, scrollHeight, clientHeight } = messagesDiv;
if (scrollHeight <= clientHeight) {
thumbEl.style.display = 'none';
return;
}
thumbEl.style.display = '';
const trackH = scrollbarEl.clientHeight;
const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH);
thumbEl.style.height = thumbH + 'px';
thumbEl.style.top = thumbTop + 'px';
}
messagesDiv.addEventListener('scroll', () => updateScrollbar(), { 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 = '';
for (const s of sessions) {
const item = document.createElement('div');
item.className = `session-item${s.id === currentSessionId ? ' active' : ''}`;
item.dataset.id = s.id;
item.innerHTML = `
${escapeHtml(s.title || 'Untitled')}
${s.hasUnread ? '' : ''}
${timeAgo(s.updated)}
`;
item.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('delete')) {
e.stopPropagation();
const doDelete = () => {
send({ type: 'delete_session', sessionId: s.id });
if (s.id === currentSessionId) {
currentSessionId = null;
messagesDiv.innerHTML = '✿
欢迎使用 CC-Web
开始与 Claude Code 对话
';
chatTitle.textContent = '新会话';
costDisplay.textContent = '';
}
};
if (skipDeleteConfirm) {
doDelete();
} else {
showDeleteConfirm(doDelete);
}
return;
}
if (target.classList.contains('edit')) {
e.stopPropagation();
startEditSessionTitle(item, s);
return;
}
send({ type: 'load_session', sessionId: s.id });
});
sessionList.appendChild(item);
}
}
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);
});
}
// --- Header title editing (contenteditable) ---
chatTitle.addEventListener('click', () => {
if (!currentSessionId || chatTitle.contentEditable === 'true') return;
const originalText = chatTitle.textContent;
chatTitle.contentEditable = 'true';
chatTitle.style.background = '#fff';
chatTitle.style.outline = '1px solid var(--accent)';
chatTitle.style.borderRadius = '6px';
chatTitle.style.padding = '2px 8px';
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 = '';
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(); }
});
});
// --- Sidebar ---
function openSidebar() {
sidebar.classList.add('open');
sidebarOverlay.hidden = false;
}
function closeSidebar() {
sidebar.classList.remove('open');
sidebarOverlay.hidden = true;
}
// --- Slash Command Menu ---
function showCmdMenu(filter) {
const filtered = SLASH_COMMANDS.filter(c =>
c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1))
);
// Exact match first (fixes /mode vs /model ambiguity)
filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0));
if (filtered.length === 0) {
hideCmdMenu();
return;
}
cmdMenuIndex = 0;
cmdMenu.innerHTML = filtered.map((c, i) =>
`
${c.cmd}
${c.desc}
`
).join('');
cmdMenu.hidden = false;
// Click handlers
cmdMenu.querySelectorAll('.cmd-item').forEach(el => {
el.addEventListener('click', () => {
const cmd = el.dataset.cmd;
if (cmd === '/model') {
hideCmdMenu();
msgInput.value = '';
showModelPicker();
return;
}
if (cmd === '/mode') {
hideCmdMenu();
msgInput.value = '';
showModePicker();
return;
}
msgInput.value = cmd + ' ';
hideCmdMenu();
msgInput.focus();
});
});
}
function hideCmdMenu() {
cmdMenu.hidden = true;
cmdMenuIndex = -1;
}
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');
}
function selectCmdMenuItem() {
const items = cmdMenu.querySelectorAll('.cmd-item');
if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) {
const cmd = items[cmdMenuIndex].dataset.cmd;
if (cmd === '/model') {
hideCmdMenu();
msgInput.value = '';
showModelPicker();
return;
}
if (cmd === '/mode') {
hideCmdMenu();
msgInput.value = '';
showModePicker();
return;
}
msgInput.value = cmd + ' ';
hideCmdMenu();
msgInput.focus();
}
}
// --- 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 = `
${escapeHtml(title)}
${options.map(opt => `
${escapeHtml(opt.label)}
${escapeHtml(opt.desc)}
${opt.value === currentValue ? '
✓' : ''}
`).join('')}
`;
const chatMain = document.querySelector('.chat-main');
chatMain.appendChild(picker);
picker.querySelectorAll('.option-picker-item').forEach(el => {
el.addEventListener('click', () => {
onSelect(el.dataset.value);
hideOptionPicker();
});
});
// 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() {
showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => {
send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode });
});
}
function showModePicker() {
showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => {
currentMode = value;
modeSelect.value = currentMode;
localStorage.setItem('cc-web-mode', currentMode);
if (currentSessionId) {
send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
}
});
}
// --- Send Message ---
function sendMessage() {
const text = msgInput.value.trim();
if (!text || isGenerating) return;
hideCmdMenu();
hideOptionPicker();
// Slash commands: don't show as user bubble
if (text.startsWith('/')) {
// /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 });
msgInput.value = '';
autoResize();
return;
}
// Regular message
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
messagesDiv.appendChild(createMsgElement('user', text));
scrollToBottom();
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode });
msgInput.value = '';
autoResize();
startGenerating();
}
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);
newChatBtn.addEventListener('click', () => send({ type: 'new_session' }));
sendBtn.addEventListener('click', sendMessage);
abortBtn.addEventListener('click', () => send({ type: 'abort' }));
// Mode selector
modeSelect.value = currentMode;
modeSelect.addEventListener('change', () => {
currentMode = modeSelect.value;
localStorage.setItem('cc-web-mode', currentMode);
if (currentSessionId) {
send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
}
});
msgInput.addEventListener('input', () => {
autoResize();
const val = msgInput.value;
// Show slash command menu
if (val.startsWith('/') && !val.includes('\n')) {
showCmdMenu(val);
} else {
hideCmdMenu();
}
});
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(); return; }
}
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();
}
}
});
// Close cmd menu on outside click
document.addEventListener('click', (e) => {
if (!cmdMenu.contains(e.target) && e.target !== msgInput) {
hideCmdMenu();
}
});
// --- 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', () => {
send({ type: 'load_session', 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;
const settingsBtn = $('#settings-btn');
const PROVIDER_OPTIONS = [
{ value: 'off', label: '关闭' },
{ value: 'pushplus', label: 'PushPlus' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'serverchan', label: 'Server酱' },
{ value: 'feishu', label: '飞书机器人' },
{ value: 'qqbot', label: 'QQ(Qmsg)' },
];
function showSettingsPanel() {
// Request current configs
send({ type: 'get_notify_config' });
send({ type: 'get_model_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 = `
⚙ 设置
模型配置
通知设置
修改密码
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
// === 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 = `⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。
`;
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 =>
``
).join('');
if (modelEditingTemplates.length === 0) {
modelCustomArea.innerHTML = `
尚无模板,点击下方按钮新建。
`;
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 = `
`;
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 = `
`;
modalOverlay.appendChild(modal);
document.body.appendChild(modalOverlay);
const closeModal = () => { 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 ===
const providerSelect = panel.querySelector('#notify-provider');
const fieldsDiv = panel.querySelector('#notify-fields');
const statusDiv = panel.querySelector('#notify-status');
const closeBtn = panel.querySelector('.settings-close');
const testBtn = panel.querySelector('#notify-test-btn');
const saveBtn = panel.querySelector('#notify-save-btn');
let currentConfig = null;
function renderFields(provider) {
fieldsDiv.innerHTML = '';
if (provider === 'pushplus') {
fieldsDiv.innerHTML = `
`;
} else if (provider === 'telegram') {
fieldsDiv.innerHTML = `
`;
} else if (provider === 'serverchan') {
fieldsDiv.innerHTML = `
`;
} else if (provider === 'feishu') {
fieldsDiv.innerHTML = `
`;
} else if (provider === 'qqbot') {
fieldsDiv.innerHTML = `
`;
}
}
providerSelect.addEventListener('change', () => renderFields(providerSelect.value));
function collectConfig() {
const provider = providerSelect.value;
const config = { 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');
config.pushplus = { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') };
config.telegram = { botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''), chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || '') };
config.serverchan = { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') };
config.feishu = { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') };
config.qqbot = { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') };
return config;
}
function showStatus(msg, type) {
statusDiv.textContent = msg;
statusDiv.className = 'settings-status ' + type;
}
_onNotifyConfig = (config) => {
currentConfig = config;
providerSelect.value = config.provider || 'off';
renderFields(config.provider || 'off');
};
_onNotifyTestResult = (msg) => {
showStatus(msg.message, msg.success ? 'success' : 'error');
};
closeBtn.addEventListener('click', hideSettingsPanel);
overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });
testBtn.addEventListener('click', () => {
// Save first then test
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 });
showStatus('已保存', 'success');
});
// Password change in settings
const settingsCurrentPw = panel.querySelector('#settings-current-pw');
const settingsNewPw = panel.querySelector('#settings-new-pw');
const settingsConfirmPw = panel.querySelector('#settings-confirm-pw');
const pwHint = panel.querySelector('#settings-pw-hint');
const pwChangeBtn = panel.querySelector('#pw-change-btn');
const pwStatus = panel.querySelector('#pw-status');
function checkSettingsPw() {
const newPw = settingsNewPw.value;
const confirmPw = settingsConfirmPw.value;
const currentPw = settingsCurrentPw.value;
if (!newPw) {
pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
pwHint.className = 'password-hint';
pwChangeBtn.disabled = true;
return;
}
const result = clientValidatePassword(newPw);
if (!result.valid) {
pwHint.textContent = result.message;
pwHint.className = 'password-hint error';
pwChangeBtn.disabled = true;
return;
}
pwHint.textContent = '密码强度符合要求';
pwHint.className = 'password-hint success';
pwChangeBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
}
settingsCurrentPw.addEventListener('input', checkSettingsPw);
settingsNewPw.addEventListener('input', checkSettingsPw);
settingsConfirmPw.addEventListener('input', checkSettingsPw);
pwChangeBtn.addEventListener('click', () => {
const currentPw = settingsCurrentPw.value;
const newPw = settingsNewPw.value;
const confirmPw = settingsConfirmPw.value;
if (newPw !== confirmPw) {
pwStatus.textContent = '两次密码不一致';
pwStatus.className = 'settings-status error';
return;
}
pwChangeBtn.disabled = true;
pwStatus.textContent = '正在修改...';
pwStatus.className = 'settings-status';
_onPasswordChanged = (result) => {
if (result.success) {
pwStatus.textContent = result.message || '密码修改成功';
pwStatus.className = 'settings-status success';
settingsCurrentPw.value = '';
settingsNewPw.value = '';
settingsConfirmPw.value = '';
pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
pwHint.className = 'password-hint';
} else {
pwStatus.textContent = result.message || '修改失败';
pwStatus.className = 'settings-status error';
pwChangeBtn.disabled = false;
}
};
send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw });
});
document.addEventListener('keydown', _settingsEscape);
}
function hideSettingsPanel() {
const overlay = document.getElementById('settings-overlay');
if (overlay) overlay.remove();
_onNotifyConfig = null;
_onNotifyTestResult = null;
_onModelConfig = 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 = `
CC
修改初始密码
首次登录需要设置新密码
`;
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();
const lastSession = localStorage.getItem('cc-web-session');
if (lastSession) {
send({ type: 'load_session', sessionId: lastSession });
}
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;
}
}
}
// --- Helpers ---
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function timeAgo(dateStr) {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}天前`;
return new Date(dateStr).toLocaleDateString('zh-CN');
}
// --- Init ---
connect();
// Register Service Worker for mobile push notifications
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').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
connect();
} else if (ws.readyState === 1 && currentSessionId) {
// WS alive, re-check session state to sync UI (fixes stuck stop button)
send({ type: 'load_session', sessionId: currentSessionId });
}
});
if (!authToken) {
loginOverlay.hidden = false;
app.hidden = true;
} else {
loginOverlay.hidden = true;
app.hidden = false;
}
})();