7335 lines
277 KiB
JavaScript
7335 lines
277 KiB
JavaScript
// === CC-Web Frontend ===
|
||
(function () {
|
||
'use strict';
|
||
|
||
const ASSET_VERSION = '20260616-composer-mcp-list';
|
||
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 SLASH_COMMANDS = [
|
||
{ cmd: '/clear', desc: '清除当前会话' },
|
||
{ cmd: '/model', desc: '查看/切换模型' },
|
||
{ cmd: '/mode', desc: '查看/切换权限模式' },
|
||
{ cmd: '/cost', desc: '查看会话费用' },
|
||
{ cmd: '/compact', desc: '压缩上下文' },
|
||
{ 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 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 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 pendingNewSessionRequest = null;
|
||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||
let pendingInitialSessionLoad = false;
|
||
let noteMode = false;
|
||
let noteDraftSeq = 0;
|
||
let isReloadingMcp = false;
|
||
const pendingNotesByTarget = new Map();
|
||
const userMessageIndex = new Map();
|
||
const expandedOldSessionAgents = 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 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="关闭">×</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 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 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 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}`);
|
||
}
|
||
|
||
async function copyTextToClipboard(text, successText = '已复制') {
|
||
const value = String(text || '');
|
||
if (!value) return false;
|
||
try {
|
||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(value);
|
||
} else {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = value;
|
||
textarea.setAttribute('readonly', '');
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.left = '-9999px';
|
||
textarea.style.top = '0';
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
textarea.remove();
|
||
}
|
||
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 = {}) {
|
||
return {
|
||
sessionId: payload.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,
|
||
totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
|
||
totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null,
|
||
updated: payload.updated || null,
|
||
isRunning: !!payload.isRunning,
|
||
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) {
|
||
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;
|
||
}
|
||
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 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 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 splitCollapsedOldSessions(regularSessions, pinnedCount) {
|
||
if (expandedOldSessionAgents.has(currentAgent)) {
|
||
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
|
||
}
|
||
|
||
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 shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread;
|
||
const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
|
||
if (canCollapse && !shouldKeepVisible) {
|
||
hiddenOldSessions.push(session);
|
||
} else {
|
||
visibleRegularSessions.push(session);
|
||
}
|
||
});
|
||
|
||
return { visibleRegularSessions, hiddenOldSessions };
|
||
}
|
||
|
||
function createOldSessionLoadMoreButton(hiddenCount) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'session-list-load-more';
|
||
button.setAttribute('aria-label', `加载更多 ${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', () => {
|
||
expandedOldSessionAgents.add(currentAgent);
|
||
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;
|
||
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}`;
|
||
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>' : ''}
|
||
</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();
|
||
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 setCurrentSessionRunningState(isRunning) {
|
||
const running = !!isRunning;
|
||
currentSessionRunning = running;
|
||
if (chatRuntimeState) {
|
||
chatRuntimeState.hidden = !running;
|
||
chatRuntimeState.textContent = running ? '运行中' : '';
|
||
}
|
||
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();
|
||
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 });
|
||
send({ type: 'load_session', 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" onclick="ccTogglePreview(this)">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" onclick="ccCopyCode(this)">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 = function (btn) {
|
||
const wrapper = btn.closest('.code-block-wrapper');
|
||
const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
|
||
const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code').textContent;
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
btn.textContent = 'Copied!';
|
||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||
});
|
||
};
|
||
|
||
window.ccTogglePreview = function (btn) {
|
||
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;
|
||
|
||
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;
|
||
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;
|
||
localStorage.setItem('cc-web-token', msg.token);
|
||
document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
|
||
loginOverlay.hidden = true;
|
||
app.hidden = false;
|
||
send({ type: 'get_codex_config' });
|
||
// Check if must change password
|
||
if (msg.mustChangePassword) {
|
||
showForceChangePassword();
|
||
} else {
|
||
pendingInitialSessionLoad = true;
|
||
}
|
||
} else {
|
||
authToken = null;
|
||
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 = msg.sessions || [];
|
||
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,
|
||
}
|
||
: 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.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.sessionId === currentSessionId && msg.message) {
|
||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||
if (welcome) welcome.remove();
|
||
messagesDiv.appendChild(buildMsgElement(msg.message));
|
||
scrollToBottom();
|
||
}
|
||
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 '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) {
|
||
for (const tc of msg.toolCalls) {
|
||
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);
|
||
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');
|
||
}
|
||
|
||
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);
|
||
}
|
||
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 createMsgElement(role, content, attachments = [], meta = {}) {
|
||
const div = document.createElement('div');
|
||
const isCrossConversation = !!meta.crossConversation;
|
||
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
|
||
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';
|
||
|
||
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);
|
||
}
|
||
|
||
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));
|
||
}
|
||
} else {
|
||
renderAssistantContent(bubble, content);
|
||
if (attachments.length > 0) {
|
||
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
|
||
}
|
||
}
|
||
|
||
hydrateAttachmentPreviews(bubble, attachments);
|
||
div.appendChild(avatar);
|
||
div.appendChild(bubble);
|
||
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 normalizeCollabAgentData(tool) {
|
||
const inputData = effectiveObject(tool?.input);
|
||
const resultData = effectiveObject(tool?.result);
|
||
const merged = {
|
||
...inputData,
|
||
...resultData,
|
||
agentsStates: resultData.agentsStates || inputData.agentsStates || {},
|
||
receiverThreadIds: resultData.receiverThreadIds || inputData.receiverThreadIds || [],
|
||
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();
|
||
const status = String(state.status || state.state || 'pending').trim() || 'pending';
|
||
const detail = String(state.candidateResult || state.finalMessage || state.summary || state.lastMessage || state.step || state.description || '').trim();
|
||
return { id, label, role, status, detail };
|
||
});
|
||
}
|
||
|
||
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 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 = 'ccweb MCP 子代理';
|
||
titleWrap.appendChild(kicker);
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'collab-agent-title';
|
||
title.textContent = data.tool || '协作任务';
|
||
titleWrap.appendChild(title);
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'collab-agent-meta';
|
||
const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0;
|
||
const agentCount = collabAgentStateEntries(data).length;
|
||
meta.textContent = `${agentCount || threadCount || 0} 个子代理`;
|
||
titleWrap.appendChild(meta);
|
||
header.appendChild(titleWrap);
|
||
|
||
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'));
|
||
header.appendChild(statusChip);
|
||
stack.appendChild(header);
|
||
|
||
const promptText = summarizePrompt(data.prompt);
|
||
if (promptText) {
|
||
const promptBlock = document.createElement('div');
|
||
promptBlock.className = 'collab-agent-prompt';
|
||
promptBlock.textContent = promptText;
|
||
stack.appendChild(promptBlock);
|
||
}
|
||
|
||
const stateEntries = collabAgentStateEntries(data);
|
||
if (stateEntries.length > 0) {
|
||
const list = document.createElement('div');
|
||
list.className = 'collab-agent-list';
|
||
stateEntries.forEach((entry, index) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'collab-agent-item';
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'collab-agent-item-row';
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'collab-agent-item-label';
|
||
label.textContent = entry.label || `子代理 ${index + 1}`;
|
||
row.appendChild(label);
|
||
|
||
const chip = document.createElement('span');
|
||
const tone = collabStateTone(entry.status);
|
||
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.role) {
|
||
const role = document.createElement('div');
|
||
role.className = 'collab-agent-item-role';
|
||
role.textContent = entry.role;
|
||
item.appendChild(role);
|
||
}
|
||
|
||
if (entry.detail) {
|
||
const detail = document.createElement('div');
|
||
detail.className = 'collab-agent-item-detail';
|
||
detail.textContent = entry.detail;
|
||
item.appendChild(detail);
|
||
}
|
||
|
||
if (entry.id) {
|
||
const footer = document.createElement('div');
|
||
footer.className = 'collab-agent-item-footer';
|
||
footer.textContent = `ID ${shortSessionId(entry.id)}`;
|
||
footer.title = entry.id;
|
||
footer.addEventListener('click', () => {
|
||
copyTextToClipboard(entry.id, '子代理线程 ID 已复制');
|
||
});
|
||
item.appendChild(footer);
|
||
}
|
||
|
||
list.appendChild(item);
|
||
});
|
||
stack.appendChild(list);
|
||
}
|
||
|
||
if (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 ${shortSessionId(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 FOLD_AT = 3;
|
||
let grouped = false;
|
||
for (const tc of m.toolCalls) {
|
||
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(bubble.children).filter(isGroupableToolCall);
|
||
if (loose.length >= FOLD_AT) {
|
||
let group = bubble.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);
|
||
bubble.insertBefore(group, bubble.firstChild);
|
||
grouped = true;
|
||
}
|
||
const inner = group.querySelector('.tool-group-inner');
|
||
loose.forEach(c => inner.appendChild(c));
|
||
_refreshGroupSummary(group);
|
||
}
|
||
bubble.appendChild(details);
|
||
}
|
||
// 结束时若出现过父目录,收尾散落项
|
||
if (grouped) {
|
||
const loose = Array.from(bubble.children).filter(isGroupableToolCall);
|
||
if (loose.length > 0) {
|
||
const group = bubble.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;
|
||
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;
|
||
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.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 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;
|
||
|
||
// 如果是 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();
|
||
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;
|
||
|
||
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 (msg.sessionId !== currentSessionId) 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 = '';
|
||
const visibleSessions = getVisibleSessions();
|
||
if (visibleSessions.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'session-list-empty';
|
||
empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话,点击“新会话”开始。`;
|
||
sessionList.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
|
||
const { visibleRegularSessions, hiddenOldSessions } = splitCollapsedOldSessions(regularSessions, pinnedSessions.length);
|
||
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);
|
||
}
|
||
|
||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleRegularSessions);
|
||
for (const group of projectGroups) {
|
||
const groupEl = document.createElement('section');
|
||
groupEl.className = 'session-project-group';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'session-project-header';
|
||
header.title = group.cwd || group.name;
|
||
header.innerHTML = `
|
||
<span class="session-project-name">${escapeHtml(group.name)}</span>
|
||
<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);
|
||
|
||
for (const s of group.sessions) {
|
||
groupEl.appendChild(createSessionListItem(s));
|
||
}
|
||
|
||
header.querySelector('.session-project-create').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
quickCreateProjectSession(group.cwd || '', { agent: currentAgent, mode: currentMode });
|
||
});
|
||
|
||
sessionList.appendChild(groupEl);
|
||
}
|
||
|
||
for (const s of ungroupedSessions) {
|
||
sessionList.appendChild(createSessionListItem(s));
|
||
}
|
||
|
||
if (hiddenOldSessions.length > 0) {
|
||
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenOldSessions.length));
|
||
}
|
||
}
|
||
|
||
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();
|
||
});
|
||
}
|
||
|
||
// 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 });
|
||
}
|
||
if (currentMode === 'default') {
|
||
appendSystemMessage('⚠ 由于项目设计与 CLI 原生逻辑不同,默认模式的授权申请功能暂未实现,建议搭配 Plan 或 YOLO 模式使用。');
|
||
}
|
||
});
|
||
|
||
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: 'QQ(Qmsg)' },
|
||
];
|
||
|
||
function buildNotifyFieldsHtml(config, provider) {
|
||
if (provider === 'pushplus') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Token</label>
|
||
<input type="text" id="notify-pushplus-token" placeholder="PushPlus Token" value="${escapeHtml(config?.pushplus?.token || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'telegram') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Bot Token</label>
|
||
<input type="text" id="notify-tg-bottoken" placeholder="123456:ABC-DEF..." value="${escapeHtml(config?.telegram?.botToken || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Chat ID</label>
|
||
<input type="text" id="notify-tg-chatid" placeholder="Chat ID" value="${escapeHtml(config?.telegram?.chatId || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'serverchan') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>SendKey</label>
|
||
<input type="text" id="notify-sc-sendkey" placeholder="Server酱 SendKey" value="${escapeHtml(config?.serverchan?.sendKey || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'feishu') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Webhook 地址</label>
|
||
<input type="text" id="notify-feishu-webhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" value="${escapeHtml(config?.feishu?.webhook || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
if (provider === 'qqbot') {
|
||
return `
|
||
<div class="settings-field">
|
||
<label>Qmsg Key</label>
|
||
<input type="text" id="notify-qmsg-key" placeholder="Qmsg 推送 Key" value="${escapeHtml(config?.qqbot?.qmsgKey || '')}">
|
||
</div>
|
||
`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function buildAgentContextCard(agent, title, copy) {
|
||
const label = AGENT_LABELS[normalizeAgent(agent)] || AGENT_LABELS.claude;
|
||
return `
|
||
<div class="agent-context-card">
|
||
<div class="agent-context-kicker">${escapeHtml(label)} Space</div>
|
||
<div class="agent-context-title">${escapeHtml(title)}</div>
|
||
<div class="agent-context-copy">${escapeHtml(copy)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderNotifyFields(fieldsDiv, config, provider) {
|
||
fieldsDiv.innerHTML = buildNotifyFieldsHtml(config, provider);
|
||
}
|
||
|
||
function collectNotifyConfigFromPanel(panel, currentConfig, provider) {
|
||
const pp = panel.querySelector('#notify-pushplus-token');
|
||
const tgBot = panel.querySelector('#notify-tg-bottoken');
|
||
const tgChat = panel.querySelector('#notify-tg-chatid');
|
||
const sc = panel.querySelector('#notify-sc-sendkey');
|
||
const feishuWh = panel.querySelector('#notify-feishu-webhook');
|
||
const qmsgKey = panel.querySelector('#notify-qmsg-key');
|
||
// Summary config
|
||
const summaryEnabled = panel.querySelector('#notify-summary-enabled');
|
||
const summaryTrigger = panel.querySelector('#notify-summary-trigger');
|
||
const summarySource = panel.querySelector('#notify-summary-source');
|
||
const summaryApiBase = panel.querySelector('#notify-summary-apibase');
|
||
const summaryApiKey = panel.querySelector('#notify-summary-apikey');
|
||
const summaryModel = panel.querySelector('#notify-summary-model');
|
||
const cs = currentConfig?.summary || {};
|
||
return {
|
||
provider,
|
||
pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') },
|
||
telegram: {
|
||
botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''),
|
||
chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || ''),
|
||
},
|
||
serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') },
|
||
feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') },
|
||
qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') },
|
||
summary: {
|
||
enabled: summaryEnabled ? summaryEnabled.checked : !!cs.enabled,
|
||
trigger: summaryTrigger ? summaryTrigger.value : (cs.trigger || 'background'),
|
||
apiSource: summarySource ? summarySource.value : (cs.apiSource || 'claude'),
|
||
apiBase: summaryApiBase ? summaryApiBase.value.trim() : (cs.apiBase || ''),
|
||
apiKey: summaryApiKey ? summaryApiKey.value.trim() : (cs.apiKey || ''),
|
||
model: summaryModel ? summaryModel.value.trim() : (cs.model || ''),
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildSummarySettingsHtml(config) {
|
||
const s = config?.summary || {};
|
||
const enabled = !!s.enabled;
|
||
const trigger = s.trigger || 'background';
|
||
const src = s.apiSource || 'claude';
|
||
const customVisible = src === 'custom' ? '' : 'display:none';
|
||
return `
|
||
<div class="settings-divider"></div>
|
||
<div class="settings-section-title">通知摘要</div>
|
||
<div class="settings-field" style="flex-direction:row;align-items:center;gap:10px">
|
||
<label style="margin:0;flex:1">启用 AI 摘要</label>
|
||
<input type="checkbox" id="notify-summary-enabled" ${enabled ? 'checked' : ''} style="width:auto;margin:0">
|
||
</div>
|
||
<div id="notify-summary-options" style="${enabled ? '' : 'display:none'}">
|
||
<div class="settings-field">
|
||
<label>推送时机</label>
|
||
<select class="settings-select" id="notify-summary-trigger">
|
||
<option value="background" ${trigger === 'background' ? 'selected' : ''}>仅后台任务</option>
|
||
<option value="always" ${trigger === 'always' ? 'selected' : ''}>所有任务</option>
|
||
</select>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>摘要 API 来源</label>
|
||
<select class="settings-select" id="notify-summary-source">
|
||
<option value="claude" ${src === 'claude' ? 'selected' : ''}>Claude 活跃模板</option>
|
||
<option value="codex" ${src === 'codex' ? 'selected' : ''}>Codex 活跃 Profile</option>
|
||
<option value="custom" ${src === 'custom' ? 'selected' : ''}>独立配置</option>
|
||
</select>
|
||
</div>
|
||
<div id="notify-summary-custom" style="${customVisible}">
|
||
<div class="settings-field">
|
||
<label>API Base URL</label>
|
||
<input type="text" id="notify-summary-apibase" placeholder="https://api.example.com" value="${escapeHtml(s.apiBase || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Key</label>
|
||
<input type="text" id="notify-summary-apikey" placeholder="sk-..." value="${escapeHtml(s.apiKey || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>模型</label>
|
||
<input type="text" id="notify-summary-model" placeholder="claude-opus-4-6" value="${escapeHtml(s.model || '')}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function bindSummarySettingsEvents(panel) {
|
||
const enabledCb = panel.querySelector('#notify-summary-enabled');
|
||
const optionsDiv = panel.querySelector('#notify-summary-options');
|
||
const sourceSelect = panel.querySelector('#notify-summary-source');
|
||
const customDiv = panel.querySelector('#notify-summary-custom');
|
||
if (!enabledCb || !optionsDiv || !sourceSelect || !customDiv) return;
|
||
enabledCb.addEventListener('change', () => {
|
||
optionsDiv.style.display = enabledCb.checked ? '' : 'none';
|
||
});
|
||
sourceSelect.addEventListener('change', () => {
|
||
customDiv.style.display = sourceSelect.value === 'custom' ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function openPasswordModal() {
|
||
const pwOverlay = document.createElement('div');
|
||
pwOverlay.className = 'settings-overlay';
|
||
pwOverlay.style.zIndex = '10001';
|
||
const pwModal = document.createElement('div');
|
||
pwModal.className = 'settings-panel';
|
||
pwModal.style.maxWidth = '400px';
|
||
pwModal.innerHTML = `
|
||
<div class="settings-header">
|
||
<h3>修改密码</h3>
|
||
<button class="settings-close" id="pw-modal-close">×</button>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>当前密码</label>
|
||
<input type="password" id="pw-modal-current" placeholder="当前密码" autocomplete="current-password">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>新密码</label>
|
||
<input type="password" id="pw-modal-new" placeholder="新密码" autocomplete="new-password">
|
||
<div class="password-hint" id="pw-modal-hint">至少 8 位,包含大写/小写/数字/特殊字符中的 2 种</div>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>确认新密码</label>
|
||
<input type="password" id="pw-modal-confirm" placeholder="确认新密码" autocomplete="new-password">
|
||
</div>
|
||
<div class="settings-actions">
|
||
<button class="btn-save" id="pw-modal-submit" disabled>修改密码</button>
|
||
</div>
|
||
<div class="settings-status" id="pw-modal-status"></div>
|
||
`;
|
||
pwOverlay.appendChild(pwModal);
|
||
document.body.appendChild(pwOverlay);
|
||
|
||
const currentPwIn = pwModal.querySelector('#pw-modal-current');
|
||
const newPwIn = pwModal.querySelector('#pw-modal-new');
|
||
const confirmPwIn = pwModal.querySelector('#pw-modal-confirm');
|
||
const hint = pwModal.querySelector('#pw-modal-hint');
|
||
const submitBtn = pwModal.querySelector('#pw-modal-submit');
|
||
const status = pwModal.querySelector('#pw-modal-status');
|
||
|
||
function checkPw() {
|
||
const newPw = newPwIn.value;
|
||
const confirmPw = confirmPwIn.value;
|
||
const currentPw = currentPwIn.value;
|
||
if (!newPw) {
|
||
hint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种';
|
||
hint.className = 'password-hint';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
const result = clientValidatePassword(newPw);
|
||
if (!result.valid) {
|
||
hint.textContent = result.message;
|
||
hint.className = 'password-hint error';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
hint.textContent = '密码强度符合要求';
|
||
hint.className = 'password-hint success';
|
||
submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
|
||
}
|
||
|
||
currentPwIn.addEventListener('input', checkPw);
|
||
newPwIn.addEventListener('input', checkPw);
|
||
confirmPwIn.addEventListener('input', checkPw);
|
||
|
||
const closePwModal = () => { document.body.removeChild(pwOverlay); };
|
||
pwModal.querySelector('#pw-modal-close').addEventListener('click', closePwModal);
|
||
pwOverlay.addEventListener('click', (e) => { if (e.target === pwOverlay) closePwModal(); });
|
||
|
||
submitBtn.addEventListener('click', () => {
|
||
const currentPw = currentPwIn.value;
|
||
const newPw = newPwIn.value;
|
||
const confirmPw = confirmPwIn.value;
|
||
if (newPw !== confirmPw) {
|
||
status.textContent = '两次密码不一致';
|
||
status.className = 'settings-status error';
|
||
return;
|
||
}
|
||
submitBtn.disabled = true;
|
||
status.textContent = '正在修改...';
|
||
status.className = 'settings-status';
|
||
_onPasswordChanged = (result) => {
|
||
if (result.success) {
|
||
status.textContent = result.message || '密码修改成功';
|
||
status.className = 'settings-status success';
|
||
setTimeout(closePwModal, 1200);
|
||
} else {
|
||
status.textContent = result.message || '修改失败';
|
||
status.className = 'settings-status error';
|
||
submitBtn.disabled = false;
|
||
}
|
||
};
|
||
send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw });
|
||
});
|
||
|
||
currentPwIn.focus();
|
||
}
|
||
|
||
function showCodexSettingsPanel() {
|
||
send({ type: 'get_codex_config' });
|
||
send({ type: 'get_notify_config' });
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay';
|
||
overlay.id = 'settings-overlay';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'settings-panel';
|
||
panel.innerHTML = `
|
||
<h3>
|
||
⚙ Codex 设置
|
||
<button class="settings-close" title="关闭">×</button>
|
||
</h3>
|
||
|
||
<div class="settings-section-title">Codex 运行配置</div>
|
||
<div class="settings-field">
|
||
<label>配置模式</label>
|
||
<select class="settings-select" id="codex-mode">
|
||
<option value="local">读取本机 Codex 登录态 / ~/.codex/config.toml</option>
|
||
<option value="custom">自定义 API Profile</option>
|
||
</select>
|
||
</div>
|
||
<div id="codex-profile-area"></div>
|
||
<div class="settings-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">×</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)}) <a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
} else {
|
||
updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
}
|
||
};
|
||
send({ type: 'check_update' });
|
||
});
|
||
|
||
window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };
|
||
|
||
closeBtn.addEventListener('click', hideSettingsPanel);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });
|
||
document.addEventListener('keydown', _settingsEscape);
|
||
}
|
||
|
||
function showSettingsPanel() {
|
||
if (isCodexLikeAgent(currentAgent)) {
|
||
showCodexSettingsPanel();
|
||
return;
|
||
}
|
||
// Request current configs (notify config is loaded on demand inside subpage)
|
||
send({ type: 'get_model_config' });
|
||
send({ type: 'get_notify_config' });
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'settings-overlay';
|
||
overlay.id = 'settings-overlay';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'settings-panel';
|
||
|
||
panel.innerHTML = `
|
||
<h3>
|
||
⚙ Claude 设置
|
||
<button class="settings-close" title="关闭">×</button>
|
||
</h3>
|
||
|
||
<div class="settings-section-title">Claude 配置</div>
|
||
<div class="settings-field">
|
||
<label>配置模式</label>
|
||
<select class="settings-select" id="model-mode">
|
||
<option value="local">读取本地配置文件 (~/.claude.json)</option>
|
||
<option value="custom">自定义配置</option>
|
||
</select>
|
||
</div>
|
||
<div id="model-custom-area"></div>
|
||
<div class="settings-actions" id="model-actions" style="display:none">
|
||
<button class="btn-save" id="model-save-btn">保存模型配置</button>
|
||
</div>
|
||
<div class="settings-status" id="model-status"></div>
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
${buildAppearanceSettingsHtml()}
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
${buildNotifyEntryHtml(null)}
|
||
|
||
<div class="settings-divider"></div>
|
||
|
||
<div class="settings-section-title">系统</div>
|
||
<div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
|
||
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
||
<button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
|
||
</div>
|
||
<div class="settings-status" id="update-status" style="margin-top:8px"></div>
|
||
`;
|
||
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
mountAppearanceSettings(panel);
|
||
const notifyPageBtn2 = panel.querySelector('[data-open-notify-page]');
|
||
if (notifyPageBtn2) notifyPageBtn2.addEventListener('click', openNotifySubpage);
|
||
|
||
// === Model Config UI ===
|
||
const modelModeSelect = panel.querySelector('#model-mode');
|
||
const modelCustomArea = panel.querySelector('#model-custom-area');
|
||
const modelActionsDiv = panel.querySelector('#model-actions');
|
||
const modelSaveBtn = panel.querySelector('#model-save-btn');
|
||
const modelStatusDiv = panel.querySelector('#model-status');
|
||
|
||
let modelCurrentConfig = null;
|
||
let modelEditingTemplates = [];
|
||
let modelActiveTemplate = '';
|
||
|
||
function showModelStatus(msg, type) {
|
||
modelStatusDiv.textContent = msg;
|
||
modelStatusDiv.className = 'settings-status ' + (type || '');
|
||
}
|
||
|
||
function renderModelCustomArea() {
|
||
if (modelModeSelect.value === 'local') {
|
||
modelCustomArea.innerHTML = `<div class="settings-field" style="color:var(--text-warning, #e8a838);font-size:0.85em">⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。</div>`;
|
||
modelActionsDiv.style.display = 'flex';
|
||
} else {
|
||
renderModelTemplateEditor();
|
||
modelActionsDiv.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function renderModelTemplateEditor() {
|
||
const activeName = modelActiveTemplate;
|
||
const tpl = modelEditingTemplates.find(t => t.name === activeName) || null;
|
||
const tplOptions = modelEditingTemplates.map(t =>
|
||
`<option value="${escapeHtml(t.name)}" ${t.name === activeName ? 'selected' : ''}>${escapeHtml(t.name)}</option>`
|
||
).join('');
|
||
|
||
if (modelEditingTemplates.length === 0) {
|
||
modelCustomArea.innerHTML = `
|
||
<div class="settings-field" style="color:var(--text-secondary);font-size:0.85em">尚无模板,点击下方按钮新建。</div>
|
||
<div class="settings-actions" style="margin-top:0">
|
||
<button class="btn-test" id="model-tpl-add-first">+ 新建模板</button>
|
||
</div>
|
||
`;
|
||
panel.querySelector('#model-tpl-add-first').addEventListener('click', () => {
|
||
const newName = prompt('输入新模板名称:');
|
||
if (!newName || !newName.trim()) return;
|
||
const n = newName.trim();
|
||
modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' });
|
||
modelActiveTemplate = n;
|
||
renderModelTemplateEditor();
|
||
});
|
||
return;
|
||
}
|
||
|
||
modelCustomArea.innerHTML = `
|
||
<div class="settings-field">
|
||
<label>激活模板</label>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
<select class="settings-select" id="model-tpl-select" style="flex:1">
|
||
${tplOptions}
|
||
<option value="__new__">+ 新建模板</option>
|
||
</select>
|
||
<button class="btn-test" id="model-tpl-edit" style="padding:4px 10px">编辑</button>
|
||
<button class="btn-test" id="model-tpl-del" title="删除" style="padding:4px 8px">删除</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
panel.querySelector('#model-tpl-select').addEventListener('change', (e) => {
|
||
if (e.target.value === '__new__') {
|
||
const newName = prompt('输入新模板名称:');
|
||
if (!newName || !newName.trim()) { e.target.value = modelActiveTemplate; return; }
|
||
const n = newName.trim();
|
||
if (modelEditingTemplates.find(t => t.name === n)) { alert('模板名称已存在'); e.target.value = modelActiveTemplate; return; }
|
||
modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' });
|
||
modelActiveTemplate = n;
|
||
renderModelTemplateEditor();
|
||
openTplEditModal();
|
||
} else {
|
||
modelActiveTemplate = e.target.value;
|
||
renderModelTemplateEditor();
|
||
}
|
||
});
|
||
|
||
panel.querySelector('#model-tpl-edit').addEventListener('click', () => {
|
||
openTplEditModal();
|
||
});
|
||
|
||
const delBtn = panel.querySelector('#model-tpl-del');
|
||
if (delBtn) {
|
||
delBtn.addEventListener('click', () => {
|
||
if (!modelActiveTemplate) return;
|
||
if (!confirm(`确认删除模板「${modelActiveTemplate}」?`)) return;
|
||
modelEditingTemplates = modelEditingTemplates.filter(t => t.name !== modelActiveTemplate);
|
||
modelActiveTemplate = modelEditingTemplates[0]?.name || '';
|
||
renderModelTemplateEditor();
|
||
});
|
||
}
|
||
}
|
||
|
||
function openTplEditModal() {
|
||
const tpl = modelEditingTemplates.find(t => t.name === modelActiveTemplate);
|
||
if (!tpl) return;
|
||
|
||
const modalOverlay = document.createElement('div');
|
||
modalOverlay.className = 'settings-overlay';
|
||
modalOverlay.style.zIndex = '10001';
|
||
const modal = document.createElement('div');
|
||
modal.className = 'settings-panel';
|
||
modal.style.maxWidth = '460px';
|
||
modal.innerHTML = `
|
||
<div class="settings-header">
|
||
<h3>编辑模板: ${escapeHtml(tpl.name)}</h3>
|
||
<button class="settings-close" id="tpl-modal-close">×</button>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>模板名称</label>
|
||
<input type="text" id="tpl-ed-name" value="${escapeHtml(tpl.name)}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Key</label>
|
||
<input type="text" id="tpl-ed-apikey" placeholder="sk-ant-..." value="${escapeHtml(tpl.apiKey || '')}">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>API Base URL</label>
|
||
<input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
|
||
</div>
|
||
|
||
<div class="settings-divider" style="margin:12px 0"></div>
|
||
|
||
<div class="settings-field">
|
||
<label style="display:flex;align-items:center;gap:8px;font-weight:600">
|
||
获取上游模型列表
|
||
</label>
|
||
<div style="display:flex;gap:6px;align-items:center;margin-top:4px">
|
||
<label style="font-size:0.85em;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||
<input type="checkbox" id="tpl-ed-custom-endpoint"> 端点
|
||
</label>
|
||
<input type="text" id="tpl-ed-models-endpoint" placeholder="/v1/models" style="flex:1;display:none" value="">
|
||
</div>
|
||
<div style="display:flex;gap:6px;margin-top:6px;align-items:center">
|
||
<button class="btn-test" id="tpl-ed-fetch-models" style="padding:4px 12px;white-space:nowrap">获取模型</button>
|
||
<span id="tpl-ed-fetch-status" style="font-size:0.85em;color:var(--text-secondary)"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-divider" style="margin:12px 0"></div>
|
||
|
||
<div class="settings-field">
|
||
<label>默认模型 (ANTHROPIC_MODEL)</label>
|
||
<input type="text" id="tpl-ed-default" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}" autocomplete="off">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Opus 模型名</label>
|
||
<input type="text" id="tpl-ed-opus" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}" autocomplete="off">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Sonnet 模型名</label>
|
||
<input type="text" id="tpl-ed-sonnet" list="tpl-dl-models" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}" autocomplete="off">
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Haiku 模型名</label>
|
||
<input type="text" id="tpl-ed-haiku" list="tpl-dl-models" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}" autocomplete="off">
|
||
</div>
|
||
<datalist id="tpl-dl-models"></datalist>
|
||
<div class="settings-actions">
|
||
<button class="btn-save" id="tpl-ed-ok">确定</button>
|
||
</div>
|
||
`;
|
||
modalOverlay.appendChild(modal);
|
||
document.body.appendChild(modalOverlay);
|
||
|
||
// Custom endpoint checkbox toggle
|
||
const customEndpointCb = modal.querySelector('#tpl-ed-custom-endpoint');
|
||
const endpointInput = modal.querySelector('#tpl-ed-models-endpoint');
|
||
customEndpointCb.addEventListener('change', () => {
|
||
endpointInput.style.display = customEndpointCb.checked ? '' : 'none';
|
||
});
|
||
|
||
// Fetch models
|
||
const fetchBtn = modal.querySelector('#tpl-ed-fetch-models');
|
||
const fetchStatus = modal.querySelector('#tpl-ed-fetch-status');
|
||
const datalist = modal.querySelector('#tpl-dl-models');
|
||
|
||
fetchBtn.addEventListener('click', () => {
|
||
const apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
|
||
const apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
|
||
if (!apiBase || !apiKey) {
|
||
fetchStatus.textContent = '请先填写 API Base 和 API Key';
|
||
fetchStatus.style.color = 'var(--text-error, #e85d5d)';
|
||
return;
|
||
}
|
||
const modelsEndpoint = customEndpointCb.checked ? endpointInput.value.trim() : '';
|
||
fetchBtn.disabled = true;
|
||
fetchStatus.textContent = '正在获取...';
|
||
fetchStatus.style.color = 'var(--text-secondary)';
|
||
|
||
_onFetchModelsResult = (result) => {
|
||
_onFetchModelsResult = null;
|
||
fetchBtn.disabled = false;
|
||
if (result.success) {
|
||
datalist.innerHTML = result.models.map(m => `<option value="${escapeHtml(m)}">`).join('');
|
||
fetchStatus.textContent = `获取到 ${result.models.length} 个模型`;
|
||
fetchStatus.style.color = 'var(--text-success, #5dbe5d)';
|
||
} else {
|
||
fetchStatus.textContent = result.message || '获取失败';
|
||
fetchStatus.style.color = 'var(--text-error, #e85d5d)';
|
||
}
|
||
};
|
||
|
||
send({ type: 'fetch_models', apiBase, apiKey, modelsEndpoint: modelsEndpoint || undefined, templateName: tpl.name });
|
||
});
|
||
|
||
const closeModal = () => {
|
||
_onFetchModelsResult = null;
|
||
document.body.removeChild(modalOverlay);
|
||
};
|
||
modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal);
|
||
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
|
||
|
||
modal.querySelector('#tpl-ed-ok').addEventListener('click', () => {
|
||
const newName = modal.querySelector('#tpl-ed-name').value.trim();
|
||
if (newName && newName !== tpl.name) {
|
||
if (modelEditingTemplates.find(t => t.name === newName && t !== tpl)) { alert('模板名称已存在'); return; }
|
||
tpl.name = newName;
|
||
modelActiveTemplate = newName;
|
||
}
|
||
tpl.apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
|
||
tpl.apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
|
||
tpl.defaultModel = modal.querySelector('#tpl-ed-default').value.trim();
|
||
tpl.opusModel = modal.querySelector('#tpl-ed-opus').value.trim();
|
||
tpl.sonnetModel = modal.querySelector('#tpl-ed-sonnet').value.trim();
|
||
tpl.haikuModel = modal.querySelector('#tpl-ed-haiku').value.trim();
|
||
closeModal();
|
||
renderModelTemplateEditor();
|
||
});
|
||
}
|
||
|
||
function saveTplFields() {
|
||
// Fields are now saved via modal, no inline fields to read
|
||
}
|
||
|
||
modelModeSelect.addEventListener('change', renderModelCustomArea);
|
||
|
||
modelSaveBtn.addEventListener('click', () => {
|
||
if (modelModeSelect.value === 'custom') saveTplFields();
|
||
const config = {
|
||
mode: modelModeSelect.value,
|
||
activeTemplate: modelActiveTemplate,
|
||
templates: modelEditingTemplates,
|
||
};
|
||
send({ type: 'save_model_config', config });
|
||
showModelStatus('已保存', 'success');
|
||
});
|
||
|
||
_onModelConfig = (config) => {
|
||
modelCurrentConfig = config;
|
||
modelEditingTemplates = (config.templates || []).map(t => Object.assign({}, t));
|
||
modelActiveTemplate = config.activeTemplate || (modelEditingTemplates[0]?.name || '');
|
||
modelModeSelect.value = config.mode || 'local';
|
||
renderModelCustomArea();
|
||
};
|
||
|
||
// === Notify Config UI (moved to subpage) ===
|
||
// notify config is handled by openNotifySubpage()
|
||
|
||
const closeBtn = panel.querySelector('.settings-close');
|
||
const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
|
||
pwOpenModalBtn.addEventListener('click', openPasswordModal);
|
||
|
||
// Check update button
|
||
const checkUpdateBtn = panel.querySelector('#check-update-btn');
|
||
const updateStatusEl = panel.querySelector('#update-status');
|
||
let _onUpdateInfo = null;
|
||
checkUpdateBtn.addEventListener('click', () => {
|
||
updateStatusEl.textContent = '正在检查...';
|
||
updateStatusEl.className = 'settings-status';
|
||
_onUpdateInfo = (info) => {
|
||
_onUpdateInfo = null;
|
||
if (info.error) {
|
||
updateStatusEl.textContent = '检查失败: ' + info.error;
|
||
updateStatusEl.className = 'settings-status error';
|
||
return;
|
||
}
|
||
if (info.hasUpdate) {
|
||
updateStatusEl.innerHTML = `有新版本 <strong>v${escapeHtml(info.latestVersion)}</strong>(当前 v${escapeHtml(info.localVersion)}) <a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
} else {
|
||
updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
|
||
updateStatusEl.className = 'settings-status success';
|
||
}
|
||
};
|
||
send({ type: 'check_update' });
|
||
});
|
||
|
||
// Wire _onUpdateInfo into WS handler via closure
|
||
const _origOnUpdateInfo = window._ccOnUpdateInfo;
|
||
window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };
|
||
|
||
closeBtn.addEventListener('click', hideSettingsPanel);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });
|
||
|
||
document.addEventListener('keydown', _settingsEscape);
|
||
}
|
||
|
||
function hideSettingsPanel() {
|
||
const overlay = document.getElementById('settings-overlay');
|
||
if (overlay) overlay.remove();
|
||
document.querySelectorAll('.settings-subpage-overlay').forEach((node) => node.remove());
|
||
_onNotifyConfig = null;
|
||
_onNotifyTestResult = null;
|
||
_onModelConfig = null;
|
||
_onCodexConfig = null;
|
||
_onFetchModelsResult = null;
|
||
window._ccOnUpdateInfo = null;
|
||
document.removeEventListener('keydown', _settingsEscape);
|
||
}
|
||
|
||
function _settingsEscape(e) {
|
||
if (e.key === 'Escape') hideSettingsPanel();
|
||
}
|
||
|
||
if (settingsBtn) {
|
||
settingsBtn.addEventListener('click', showSettingsPanel);
|
||
}
|
||
|
||
// --- Force Change Password ---
|
||
function showForceChangePassword() {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'force-change-overlay';
|
||
overlay.id = 'force-change-overlay';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'force-change-panel';
|
||
|
||
panel.innerHTML = `
|
||
<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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function timeAgo(dateStr) {
|
||
if (!dateStr) return '';
|
||
const diff = Date.now() - new Date(dateStr).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return '刚刚';
|
||
if (mins < 60) return `${mins}分钟前`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `${hours}小时前`;
|
||
const days = Math.floor(hours / 24);
|
||
if (days < 30) return `${days}天前`;
|
||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||
}
|
||
|
||
// --- Init ---
|
||
applyTheme(currentTheme);
|
||
setCurrentAgent(currentAgent);
|
||
updateNoteModeUI();
|
||
renderSessionList();
|
||
connect();
|
||
window.addEventListener('resize', updateCwdBadge);
|
||
window.addEventListener('online', () => {
|
||
reconnectAttempts = 0;
|
||
connect();
|
||
});
|
||
window.addEventListener('offline', clearReconnectTimer);
|
||
window.addEventListener('pagehide', () => {
|
||
isPageUnloading = true;
|
||
clearReconnectTimer();
|
||
if (ws && ws.readyState <= 1) {
|
||
ws.close(1001, 'pagehide');
|
||
}
|
||
});
|
||
window.addEventListener('pageshow', () => {
|
||
isPageUnloading = false;
|
||
if (!ws || ws.readyState > 1) {
|
||
reconnectAttempts = 0;
|
||
connect();
|
||
}
|
||
});
|
||
|
||
// Register Service Worker for mobile push notifications
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {});
|
||
}
|
||
|
||
// Restore remembered password
|
||
const savedPw = localStorage.getItem('cc-web-pw');
|
||
if (savedPw) {
|
||
loginPassword.value = savedPw;
|
||
rememberPw.checked = true;
|
||
}
|
||
|
||
// Visibility change: re-sync state when user returns to tab (critical for mobile)
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (!ws || ws.readyState > 1) {
|
||
// WS is dead, force reconnect
|
||
reconnectAttempts = 0;
|
||
connect();
|
||
} else if (ws.readyState === 1 && currentSessionId) {
|
||
// Preserve active streaming UI when returning to foreground.
|
||
if (isGenerating || currentSessionRunning) {
|
||
send({ type: 'load_session', sessionId: currentSessionId });
|
||
} else {
|
||
beginSessionSwitch(currentSessionId, { blocking: false, force: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!authToken) {
|
||
loginOverlay.hidden = false;
|
||
app.hidden = true;
|
||
} else {
|
||
loginOverlay.hidden = true;
|
||
app.hidden = false;
|
||
}
|
||
})();
|