feat: update session workspace flow and web ui

This commit is contained in:
shiyue
2026-03-30 04:31:25 +08:00
parent a29af2767e
commit a3df0cc6f0
9 changed files with 1546 additions and 55 deletions

View File

@@ -5,6 +5,7 @@
### 改进
- Claude 默认设置为 1M 上下文opus / sonnet 自动使用 `[1m]` 模型haiku 保持不变)
- 新会话弹窗补充工作目录默认提示、最近目录快捷项和目录选择器
## v1.2.10

View File

@@ -26,6 +26,7 @@ https://github.com/ZgDaniel/cc-web 给我装!
- **超轻量** — 后端性能占用少,前端通过 web 访问
- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex沿用相同的 Web 会话与后台任务模型
- **工作目录更顺手** — 新会话弹窗会显示默认目录、最近目录快捷项,也能直接弹出目录选择器
- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰
- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式
- **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录

View File

@@ -339,9 +339,22 @@ function createAgentRuntime(deps) {
if (!item || !item.id) break;
if (item.type === 'agent_message') {
if (item.text) {
let parsedContent = null;
try {
parsedContent = JSON.parse(item.text);
} catch {}
if (parsedContent && Array.isArray(parsedContent)) {
if (!entry.contentBlocks) entry.contentBlocks = [];
entry.contentBlocks.push(...parsedContent);
const textOnly = parsedContent.filter(b => b.type === 'text').map(b => b.text || '').join('');
entry.fullText += textOnly;
wsSend(entry.ws, { type: 'content_blocks', blocks: parsedContent });
} else {
entry.fullText += item.text;
wsSend(entry.ws, { type: 'text_delta', text: item.text });
}
}
break;
}
const tc = ensureCodexToolCall(entry, item);

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cc-web",
"version": "1.2.8",
"version": "1.2.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cc-web",
"version": "1.2.8",
"version": "1.2.11",
"dependencies": {
"ws": "^8.18.0"
}

View File

@@ -100,6 +100,8 @@
let loginPasswordValue = ''; // store login password for force-change flow
let currentCwd = null;
let currentSessionRunning = false;
let fileBrowserState = null;
let directoryPickerState = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
@@ -552,6 +554,495 @@
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 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;
@@ -794,22 +1285,21 @@
return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent);
}
function shouldOverlayRuntimeBadge() {
return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches;
}
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;
chatCwd.title = `${currentCwd}\n点击浏览目录和文件`;
chatCwd.setAttribute('aria-label', `浏览工作目录 ${currentCwd}`);
} else {
chatCwd.textContent = '';
chatCwd.title = '';
chatCwd.removeAttribute('aria-label');
}
chatCwd.hidden = !currentCwd || (currentSessionRunning && shouldOverlayRuntimeBadge());
chatCwd.disabled = !currentCwd;
chatCwd.hidden = !currentCwd;
}
function setCurrentSessionRunningState(isRunning) {
@@ -862,6 +1352,7 @@
function resetChatView(agent) {
setCurrentAgent(agent);
closeFileBrowser();
currentSessionId = null;
loadedHistorySessionId = null;
clearSessionLoading();
@@ -885,6 +1376,9 @@
function applySessionSnapshot(snapshot, options = {}) {
if (!snapshot) return;
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;
@@ -1303,6 +1797,15 @@
scheduleRender();
break;
case 'content_blocks':
if (!isGenerating) startGenerating();
if (Array.isArray(msg.blocks)) {
if (!window.pendingContentBlocks) window.pendingContentBlocks = [];
window.pendingContentBlocks.push(...msg.blocks);
scheduleRender();
}
break;
case 'tool_start':
if (!isGenerating) startGenerating();
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
@@ -1461,7 +1964,7 @@
break;
case 'cwd_suggestions':
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg.paths || []);
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg);
break;
case 'update_info':
@@ -1475,6 +1978,7 @@
isGenerating = true;
setCurrentSessionRunningState(true);
pendingText = '';
window.pendingContentBlocks = [];
activeToolCalls.clear();
toolGroupCount = 0;
hasGrouped = false;
@@ -1508,7 +2012,11 @@
setCurrentSessionRunningState(false);
msgInput.focus();
if (pendingText) flushRender();
if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
flushRender();
}
window.pendingContentBlocks = [];
const typing = document.querySelector('.typing-indicator');
if (typing) typing.remove();
@@ -1563,9 +2071,15 @@
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();
}
@@ -1612,7 +2126,7 @@
bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments));
}
} else {
bubble.innerHTML = content ? renderMarkdown(content) : '';
renderAssistantContent(bubble, content);
if (attachments.length > 0) {
bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments));
}
@@ -1623,6 +2137,74 @@
return div;
}
function renderAssistantContent(bubble, content) {
if (!content) return;
if (typeof content === 'string') {
// 尝试检测是否是 JSON 格式的 todo_list
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 {}
}
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) {
@@ -1631,6 +2213,11 @@
function toolTitle(tool) {
if (tool?.meta?.title) return tool.meta.title;
if (toolKind(tool) === 'file_change') {
const filePath = tool?.meta?.subtitle || tool?.input?.file_path || '';
const action = tool?.input?.new_string && tool?.input?.old_string ? '更新' : '创建';
return filePath ? `${action} ${filePath}` : 'File Change';
}
return tool?.name || 'Tool';
}
@@ -2419,6 +3006,13 @@
});
});
if (chatCwd) {
chatCwd.addEventListener('click', () => {
if (!currentCwd) return;
showFileBrowser();
});
}
// --- Sidebar ---
function openSidebar() {
sidebar.classList.add('open');
@@ -4048,6 +4642,12 @@
function showNewSessionModal() {
const targetAgent = currentAgent;
const targetLabel = AGENT_LABELS[targetAgent] || AGENT_LABELS.claude;
const recentCwds = getRecentCwds();
let suggestionsRequested = false;
let suggestionState = {
defaultPath: '',
paths: [],
};
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'new-session-overlay';
@@ -4065,8 +4665,11 @@
<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">
<datalist id="ns-cwd-list"></datalist>
<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>
@@ -4081,33 +4684,111 @@
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');
// Populate datalist with recent cwds + server suggestions
function renderCwdOptions(serverPaths) {
const recent = getRecentCwds();
cwdInput.value = recentCwds[0] || '';
function getMergedCwdSuggestions() {
const seen = new Set();
let html = '';
// Recent cwds first
for (const p of recent) {
if (!seen.has(p)) { seen.add(p); html += `<option value="${escapeHtml(p)}" label="最近"></option>`; }
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);
}
// Server suggestions
for (const p of (serverPaths || [])) {
if (!seen.has(p)) { seen.add(p); html += `<option value="${escapeHtml(p)}"></option>`; }
}
cwdList.innerHTML = html;
return merged;
}
// Pre-fill with local recent cwds immediately
renderCwdOptions([]);
function getEffectiveCwd() {
return cwdInput.value.trim() || suggestionState.defaultPath || null;
}
// Fetch server suggestions on focus
cwdInput.addEventListener('focus', () => {
_onCwdSuggestions = (paths) => { renderCwdOptions(paths); };
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();
close();
if (cwd) saveRecentCwd(cwd);
send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode });
}
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;
}
@@ -4115,13 +4796,7 @@
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(); });
overlay.querySelector('#ns-create-btn').addEventListener('click', () => {
const cwd = cwdInput.value.trim() || null;
close();
if (cwd) saveRecentCwd(cwd);
send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode });
});
createBtn.addEventListener('click', createSession);
cwdInput.focus();
}

View File

@@ -66,7 +66,7 @@
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
</div>
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
<span id="chat-cwd" class="chat-cwd" hidden></span>
<button id="chat-cwd" class="chat-cwd" type="button" hidden></button>
<select id="mode-select" class="mode-select" title="权限模式">
<option value="yolo">YOLO</option>
<option value="default">默认</option>

View File

@@ -1790,6 +1790,10 @@ body.session-loading-active {
.session-item-actions { display: flex; }
.cmd-menu { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
.option-picker { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
.chat-header {
padding: 0 10px;
gap: 6px;
}
.chat-title {
font-size: 13px;
}
@@ -1800,8 +1804,17 @@ body.session-loading-active {
right: 10px;
min-width: 138px;
}
.chat-runtime-state {
padding: 2px 8px;
font-size: 10px;
}
.chat-cwd {
max-width: 120px;
max-width: 92px;
flex-shrink: 1;
}
.mode-select {
padding: 4px 20px 4px 8px;
font-size: 11px;
}
}
@@ -1817,6 +1830,11 @@ body.session-loading-active {
.new-chat-btn,
.new-chat-arrow { min-height: 44px; }
.new-chat-arrow { width: 48px; }
.chat-cwd {
max-width: 72px;
padding-left: 6px;
padding-right: 6px;
}
}
/* === Utility === */
@@ -2379,6 +2397,8 @@ html[data-theme='coolvibe'] .settings-back:hover {
/* === Chat CWD label === */
.chat-cwd {
appearance: none;
border: none;
font-size: 11px;
color: var(--text-muted);
background: var(--bg-tertiary);
@@ -2389,8 +2409,18 @@ html[data-theme='coolvibe'] .settings-back:hover {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
font-family: inherit;
line-height: 1.25;
text-align: left;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chat-cwd:hover { background: var(--accent-light); color: var(--accent); }
.chat-cwd:focus-visible {
outline: 2px solid rgba(192, 85, 58, 0.24);
outline-offset: 2px;
}
.chat-cwd:disabled { cursor: default; opacity: 0.7; }
/* === Modal Overlay === */
.modal-overlay {
@@ -2519,6 +2549,42 @@ html[data-theme='coolvibe'] .settings-back:hover {
transition: background 0.2s;
}
.modal-btn-secondary:hover { background: var(--bg-secondary); }
.modal-btn-inline {
flex: 0 0 auto;
white-space: nowrap;
}
.modal-field-hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
word-break: break-all;
}
.modal-quick-picks {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.modal-quick-pick {
max-width: 100%;
padding: 7px 10px;
border: 1px solid rgba(192, 85, 58, 0.14);
border-radius: 999px;
background: rgba(255, 252, 248, 0.96);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
word-break: break-all;
}
.modal-quick-pick:hover {
background: var(--accent-light);
color: var(--accent);
border-color: rgba(192, 85, 58, 0.24);
}
.modal-loading, .modal-empty {
text-align: center;
color: var(--text-muted);
@@ -2595,4 +2661,315 @@ html[data-theme='coolvibe'] .settings-back:hover {
cursor: pointer;
transition: background 0.2s;
}
/* Todo List */
.todo-list-container {
margin: 12px 0;
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--bg-tertiary);
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.todo-item:last-child {
border-bottom: none;
}
.todo-checkbox {
flex-shrink: 0;
width: 18px;
height: 18px;
cursor: pointer;
}
.todo-text {
flex: 1;
font-size: 14px;
color: var(--text-primary);
line-height: 1.5;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: var(--text-muted);
opacity: 0.6;
}
.import-item-btn:hover { background: var(--accent-hover); }
/* === File Browser === */
.directory-picker-panel,
.file-browser-panel {
max-width: 980px;
height: min(82vh, 760px);
}
.file-browser-body {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.file-browser-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.file-browser-toolbar-btn {
appearance: none;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 10px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.file-browser-toolbar-btn:hover:not(:disabled) {
background: var(--accent-light);
color: var(--accent);
}
.file-browser-toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-browser-path {
min-width: 0;
flex: 1;
padding: 9px 12px;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
word-break: break-all;
}
.file-browser-status {
min-height: 18px;
font-size: 12px;
color: var(--text-muted);
}
.file-browser-status[data-state='error'] {
color: var(--danger);
}
.file-browser-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 12px;
}
.file-browser-pane {
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
background: var(--bg-primary);
}
.file-browser-pane-title {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.file-browser-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.file-browser-item {
appearance: none;
width: 100%;
border: none;
background: transparent;
border-radius: 12px;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.file-browser-item:hover {
background: var(--bg-tertiary);
}
.file-browser-item.active {
background: var(--accent-light);
}
.file-browser-item-icon {
min-width: 42px;
height: 24px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(192, 85, 58, 0.12);
border: 1px solid rgba(192, 85, 58, 0.16);
color: var(--accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
}
.file-browser-item.directory .file-browser-item-icon {
background: rgba(93, 138, 84, 0.12);
border-color: rgba(93, 138, 84, 0.18);
color: #4e7f46;
}
.file-browser-item-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.file-browser-item-name {
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-browser-item-meta {
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-browser-preview-pane {
min-height: 0;
display: flex;
flex-direction: column;
}
.file-browser-preview-header {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
.file-browser-preview-copy {
flex: 1;
min-width: 0;
}
.file-browser-preview-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-browser-preview-meta {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.file-browser-mobile-back {
display: none;
appearance: none;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 10px;
padding: 7px 10px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
flex-shrink: 0;
}
.file-browser-preview-empty {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
line-height: 1.6;
}
.file-browser-preview-content {
flex: 1;
min-height: 0;
margin: 0;
padding: 14px 16px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.65;
color: var(--text-primary);
background: var(--bg-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (max-width: 768px) {
.modal-field-row {
flex-wrap: wrap;
}
.modal-btn-inline {
width: 100%;
}
.file-browser-panel {
max-width: none;
width: 100%;
height: calc(100vh - 20px);
}
.directory-picker-panel {
max-width: none;
width: 100%;
height: calc(100vh - 20px);
}
.file-browser-body {
padding: 14px;
}
.file-browser-toolbar {
align-items: stretch;
}
.file-browser-path {
width: 100%;
order: 3;
}
.file-browser-layout {
display: block;
}
.file-browser-panel:not(.preview-active) .file-browser-preview-pane {
display: none;
}
.file-browser-panel.preview-active .file-browser-list-pane {
display: none;
}
.file-browser-mobile-back {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
@media (max-width: 480px) {
.file-browser-preview-header {
flex-direction: column;
}
}

View File

@@ -117,6 +117,17 @@ async function uploadAttachment(port, token, { filename, mime, data }) {
return payload.attachment;
}
async function fetchAuthedJson(port, token, pathname) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const payload = await response.json();
assert(response.ok && payload.ok, `Request failed for ${pathname}: ${payload.message || response.status}`);
return payload;
}
function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
const callSite = (() => {
const stack = String(new Error().stack || '').split('\n');
@@ -333,6 +344,22 @@ async function main() {
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const pickerRoot = path.join(homeDir, 'picker-root');
mkdirp(path.join(pickerRoot, 'alpha'));
mkdirp(path.join(pickerRoot, 'beta'));
fs.writeFileSync(path.join(pickerRoot, 'note.txt'), 'not a directory');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'plan' }));
const defaultCodexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat');
assert(defaultCodexSession.cwd === homeDir, 'Codex new_session without cwd should default to HOME');
const directoryPayload = await fetchAuthedJson(port, token, `/api/fs/directories?path=${encodeURIComponent(pickerRoot)}`);
assert(directoryPayload.currentPath === pickerRoot, 'Directory picker should return requested absolute path');
assert(directoryPayload.defaultPath === homeDir, 'Directory picker should expose HOME as default path');
assert(directoryPayload.entries.some((entry) => entry.name === 'alpha'), 'Directory picker should list child directories');
assert(directoryPayload.entries.some((entry) => entry.name === 'beta'), 'Directory picker should include all child directories');
assert(!directoryPayload.entries.some((entry) => entry.name === 'note.txt'), 'Directory picker should hide files');
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
@@ -356,6 +383,11 @@ async function main() {
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
assert(codexSession.model === 'gpt-5.4', 'Codex new_session should inject default model gpt-5.4');
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
assert(Array.isArray(cwdSuggestions.paths) && cwdSuggestions.paths.includes(codexInitCwd), 'CWD suggestions should include recently used session directories');
ws.send(JSON.stringify({ type: 'message', text: '/init', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexInitStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /AGENTS\.md/.test(msg.message || ''));
assert(/AGENTS\.md/.test(codexInitStart.message || ''), 'Codex /init should announce AGENTS.md generation');

414
server.js
View File

@@ -27,7 +27,16 @@ const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const MAX_MESSAGE_ATTACHMENTS = 4;
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const TEXT_PREVIEW_EXTENSIONS = new Set([
'.txt', '.md', '.markdown', '.json', '.jsonl', '.js', '.jsx', '.ts', '.tsx',
'.css', '.scss', '.less', '.html', '.htm', '.xml', '.svg', '.yml', '.yaml',
'.toml', '.ini', '.conf', '.config', '.env', '.log', '.sh', '.bash', '.zsh',
'.py', '.rb', '.go', '.java', '.kt', '.c', '.cc', '.cpp', '.h', '.hpp',
'.cs', '.sql', '.csv', '.tsx', '.vue', '.svelte', '.lock',
]);
const NOTIFY_CONFIG_PATH = path.join(CONFIG_DIR, 'notify.json');
const AUTH_CONFIG_PATH = path.join(CONFIG_DIR, 'auth.json');
const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
@@ -896,6 +905,376 @@ function jsonResponse(res, statusCode, payload) {
res.end(JSON.stringify(payload));
}
function normalizeRelativeBrowserPath(input) {
return String(input || '')
.replace(/\\/g, '/')
.split('/')
.filter((part) => part && part !== '.')
.join('/');
}
function isPathInside(parentPath, targetPath) {
const relative = path.relative(parentPath, targetPath);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function normalizeExistingDirPath(candidate) {
if (!candidate) return null;
try {
const resolvedPath = path.resolve(String(candidate));
if (!fs.existsSync(resolvedPath)) return null;
const realPath = fs.realpathSync(resolvedPath);
if (!fs.statSync(realPath).isDirectory()) return null;
return realPath;
} catch {
return null;
}
}
function getDefaultSessionCwd() {
return normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || process.cwd())
|| normalizeExistingDirPath(process.cwd())
|| path.resolve(process.cwd());
}
function collectRecentSessionCwds(limit = 12) {
const results = [];
const seen = new Set();
const pushPath = (candidate) => {
const normalized = normalizeExistingDirPath(candidate);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
results.push(normalized);
};
pushPath(getDefaultSessionCwd());
pushPath(process.cwd());
if (process.platform === 'win32') {
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
pushPath(`${letter}:\\`);
if (results.length >= limit) return results.slice(0, limit);
}
}
try {
const files = fs.readdirSync(SESSIONS_DIR)
.filter((name) => name.endsWith('.json'))
.map((name) => {
const fullPath = path.join(SESSIONS_DIR, name);
let updatedAt = 0;
try {
updatedAt = fs.statSync(fullPath).mtimeMs;
} catch {}
return { name, updatedAt };
})
.sort((a, b) => b.updatedAt - a.updatedAt);
for (const file of files) {
try {
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file.name), 'utf8')));
pushPath(session.cwd);
if (results.length >= limit) break;
} catch {}
}
} catch {}
return results.slice(0, limit);
}
function resolveDirectoryPickerTarget(requestedPath) {
const fallbackPath = getDefaultSessionCwd();
try {
const rawPath = String(requestedPath || '').trim();
const candidatePath = rawPath ? path.resolve(rawPath) : fallbackPath;
if (!fs.existsSync(candidatePath)) {
return { ok: false, statusCode: 404, message: '目标目录不存在' };
}
const realPath = fs.realpathSync(candidatePath);
const stat = fs.statSync(realPath);
if (!stat.isDirectory()) {
return { ok: false, statusCode: 400, message: '目标路径不是目录' };
}
const parentPath = path.dirname(realPath);
return {
ok: true,
realPath,
parentPath: parentPath === realPath ? '' : parentPath,
defaultPath: fallbackPath,
};
} catch (err) {
return { ok: false, statusCode: 500, message: `解析目录失败: ${err.message}` };
}
}
function getSessionBrowseContext(sessionId) {
const session = loadSession(sessionId);
if (!session) {
return { ok: false, statusCode: 404, message: '会话不存在' };
}
const rootCandidate = session.cwd || activeProcesses.get(sessionId)?.cwd || null;
if (!rootCandidate) {
return { ok: false, statusCode: 400, message: '当前会话没有可浏览的工作目录' };
}
try {
const resolvedRoot = path.resolve(String(rootCandidate));
if (!fs.existsSync(resolvedRoot)) {
return { ok: false, statusCode: 404, message: '工作目录不存在' };
}
const realRoot = fs.realpathSync(resolvedRoot);
const stat = fs.statSync(realRoot);
if (!stat.isDirectory()) {
return { ok: false, statusCode: 400, message: '工作目录不是目录' };
}
return { ok: true, session, rootDir: realRoot };
} catch (err) {
return { ok: false, statusCode: 500, message: `解析工作目录失败: ${err.message}` };
}
}
function resolveBrowseTarget(rootDir, requestedPath) {
try {
const candidatePath = requestedPath
? path.resolve(rootDir, String(requestedPath))
: rootDir;
if (!fs.existsSync(candidatePath)) {
return { ok: false, statusCode: 404, message: '目标路径不存在' };
}
const realPath = fs.realpathSync(candidatePath);
if (!isPathInside(rootDir, realPath)) {
return { ok: false, statusCode: 403, message: '目标路径超出允许范围' };
}
return {
ok: true,
realPath,
relativePath: normalizeRelativeBrowserPath(path.relative(rootDir, realPath)),
};
} catch (err) {
return { ok: false, statusCode: 500, message: `解析目标路径失败: ${err.message}` };
}
}
function isPreviewTextExtension(filePath) {
return TEXT_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function isProbablyTextBuffer(buffer) {
if (!buffer || buffer.length === 0) return true;
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
let suspicious = 0;
for (const byte of sample) {
if (byte === 0) return false;
if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
}
return suspicious / sample.length < 0.05;
}
function readFilePreviewBuffer(filePath, maxBytes) {
const previewSize = Math.max(0, Math.min(maxBytes, fs.statSync(filePath).size));
if (previewSize === 0) return Buffer.alloc(0);
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(previewSize);
const read = fs.readSync(fd, buffer, 0, previewSize, 0);
return buffer.subarray(0, read);
} finally {
fs.closeSync(fd);
}
}
function handleFileSystemListApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
if (!sessionId) {
return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
}
const browseContext = getSessionBrowseContext(sessionId);
if (!browseContext.ok) {
return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
}
const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let stat;
try {
stat = fs.statSync(target.realPath);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
}
if (!stat.isDirectory()) {
return jsonResponse(res, 400, { ok: false, message: '目标路径不是目录' });
}
let dirEntries = [];
try {
dirEntries = fs.readdirSync(target.realPath, { withFileTypes: true });
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
}
const entries = [];
for (const entry of dirEntries) {
const rawEntryPath = path.join(target.realPath, entry.name);
try {
const lstat = fs.lstatSync(rawEntryPath);
const symlink = lstat.isSymbolicLink();
const resolvedEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
if (!isPathInside(browseContext.rootDir, resolvedEntryPath)) continue;
const finalStat = symlink ? fs.statSync(rawEntryPath) : lstat;
const kind = finalStat.isDirectory() ? 'directory' : finalStat.isFile() ? 'file' : null;
if (!kind) continue;
const childRelativePath = normalizeRelativeBrowserPath(
target.relativePath ? `${target.relativePath}/${entry.name}` : entry.name
);
entries.push({
name: entry.name,
path: childRelativePath,
kind,
size: kind === 'file' ? finalStat.size : 0,
updatedAt: finalStat.mtime.toISOString(),
previewableHint: kind === 'file' && (isPreviewTextExtension(entry.name) || !path.extname(entry.name)),
symlink,
});
} catch {}
}
entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
});
const parentPath = target.relativePath
? normalizeRelativeBrowserPath(path.posix.dirname(target.relativePath))
: null;
return jsonResponse(res, 200, {
ok: true,
sessionId,
rootPath: browseContext.rootDir,
currentPath: target.relativePath,
currentDisplayPath: target.realPath,
parentPath: parentPath && parentPath !== '.' ? parentPath : '',
truncated: entries.length > FILE_BROWSER_MAX_LIST_ENTRIES,
entryLimit: FILE_BROWSER_MAX_LIST_ENTRIES,
entries: entries.slice(0, FILE_BROWSER_MAX_LIST_ENTRIES),
});
}
function handleFileSystemReadApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
if (!sessionId) {
return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
}
const browseContext = getSessionBrowseContext(sessionId);
if (!browseContext.ok) {
return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
}
const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let stat;
try {
stat = fs.statSync(target.realPath);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取文件失败: ${err.message}` });
}
if (!stat.isFile()) {
return jsonResponse(res, 400, { ok: false, message: '目标路径不是文件' });
}
let previewBuffer;
try {
previewBuffer = readFilePreviewBuffer(target.realPath, FILE_BROWSER_MAX_PREVIEW_BYTES);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取文件失败: ${err.message}` });
}
if (!isPreviewTextExtension(target.realPath) && !isProbablyTextBuffer(previewBuffer)) {
return jsonResponse(res, 415, { ok: false, message: '当前仅支持预览简单文本文件' });
}
return jsonResponse(res, 200, {
ok: true,
sessionId,
rootPath: browseContext.rootDir,
path: target.relativePath,
name: path.basename(target.realPath),
size: stat.size,
updatedAt: stat.mtime.toISOString(),
truncated: stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES,
previewBytes: previewBuffer.length,
content: previewBuffer.toString('utf8'),
});
}
function handleDirectoryPickerListApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const target = resolveDirectoryPickerTarget(url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let dirEntries = [];
try {
dirEntries = fs.readdirSync(target.realPath, { withFileTypes: true });
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
}
const entries = [];
for (const entry of dirEntries) {
const rawEntryPath = path.join(target.realPath, entry.name);
try {
const lstat = fs.lstatSync(rawEntryPath);
const symlink = lstat.isSymbolicLink();
const realEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
const stat = symlink ? fs.statSync(rawEntryPath) : lstat;
if (!stat.isDirectory()) continue;
entries.push({
name: entry.name,
path: realEntryPath,
kind: 'directory',
updatedAt: stat.mtime.toISOString(),
symlink,
});
} catch {}
}
entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
return jsonResponse(res, 200, {
ok: true,
defaultPath: target.defaultPath,
currentPath: target.realPath,
parentPath: target.parentPath,
truncated: entries.length > FILE_BROWSER_MAX_LIST_ENTRIES,
entryLimit: FILE_BROWSER_MAX_LIST_ENTRIES,
entries: entries.slice(0, FILE_BROWSER_MAX_LIST_ENTRIES),
});
}
const INITIAL_HISTORY_COUNT = 12;
const HISTORY_CHUNK_SIZE = 24;
@@ -1275,10 +1654,10 @@ function handleProcessComplete(sessionId, exitCode, signal) {
// Save result to session
const session = loadSession(sessionId);
if (session && entry.fullText) {
if (session && (entry.fullText || entry.contentBlocks)) {
session.messages.push({
role: 'assistant',
content: entry.fullText,
content: entry.contentBlocks || entry.fullText,
toolCalls: entry.toolCalls || [],
timestamp: new Date().toISOString(),
});
@@ -1564,6 +1943,18 @@ const server = http.createServer((req, res) => {
return jsonResponse(res, 200, { ok: true });
}
if (req.method === 'GET' && url.pathname === '/api/fs/list') {
return handleFileSystemListApi(req, res, url);
}
if (req.method === 'GET' && url.pathname === '/api/fs/read') {
return handleFileSystemReadApi(req, res, url);
}
if (req.method === 'GET' && url.pathname === '/api/fs/directories') {
return handleDirectoryPickerListApi(req, res, url);
}
let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
filePath = path.resolve(filePath);
@@ -2173,7 +2564,10 @@ function handleNewSession(ws, msg) {
const cwd = (msg && msg.cwd) ? String(msg.cwd) : null;
const agent = normalizeAgent(msg?.agent);
const requestedMode = ['default', 'plan', 'yolo'].includes(msg?.mode) ? msg.mode : 'yolo';
const resolvedCwd = cwd || (agent === 'claude' ? (process.env.HOME || process.env.USERPROFILE || process.cwd()) : null);
if (cwd && !normalizeExistingDirPath(cwd)) {
return wsSend(ws, { type: 'error', message: '工作目录不存在或不可访问,请重新选择。' });
}
const resolvedCwd = normalizeExistingDirPath(cwd) || getDefaultSessionCwd();
const id = crypto.randomUUID();
const session = {
id,
@@ -2490,7 +2884,7 @@ function handleMessage(ws, msg, options = {}) {
if (!session) {
const id = crypto.randomUUID();
const agent = normalizeAgent(msg.agent);
const resolvedCwd = agent === 'claude' ? (process.env.HOME || process.env.USERPROFILE || process.cwd()) : null;
const resolvedCwd = getDefaultSessionCwd();
session = {
id,
title: derivedTitle,
@@ -3135,11 +3529,9 @@ function handleImportCodexSession(ws, msg) {
}
function handleListCwdSuggestions(ws) {
const paths = new Set();
// Always include HOME
const home = process.env.HOME || process.env.USERPROFILE || '';
if (home) paths.add(home);
wsSend(ws, { type: 'cwd_suggestions', paths: Array.from(paths).sort() });
const defaultPath = getDefaultSessionCwd();
const paths = collectRecentSessionCwds(12).filter((candidate) => candidate !== defaultPath);
wsSend(ws, { type: 'cwd_suggestions', defaultPath, paths });
}
// === Startup ===
@@ -3165,6 +3557,6 @@ setInterval(() => {
plog('INFO', 'server_start', { port: PORT });
server.listen(PORT, '127.0.0.1', () => {
console.log(`CC-Web server listening on 127.0.0.1:${PORT}`);
server.listen(PORT, '0.0.0.0', () => {
console.log(`CC-Web server listening on 0.0.0.0:${PORT}`);
});