feat: update session workspace flow and web ui
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### 改进
|
||||
|
||||
- Claude 默认设置为 1M 上下文(opus / sonnet 自动使用 `[1m]` 模型,haiku 保持不变)
|
||||
- 新会话弹窗补充工作目录默认提示、最近目录快捷项和目录选择器
|
||||
|
||||
## v1.2.10
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ https://github.com/ZgDaniel/cc-web 给我装!
|
||||
|
||||
- **超轻量** — 后端性能占用少,前端通过 web 访问
|
||||
- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex,沿用相同的 Web 会话与后台任务模型
|
||||
- **工作目录更顺手** — 新会话弹窗会显示默认目录、最近目录快捷项,也能直接弹出目录选择器
|
||||
- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰
|
||||
- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式
|
||||
- **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录
|
||||
|
||||
@@ -339,8 +339,21 @@ function createAgentRuntime(deps) {
|
||||
if (!item || !item.id) break;
|
||||
if (item.type === 'agent_message') {
|
||||
if (item.text) {
|
||||
entry.fullText += item.text;
|
||||
wsSend(entry.ws, { type: 'text_delta', text: 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;
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
749
public/app.js
749
public/app.js
@@ -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;
|
||||
let textDiv = bubble.querySelector('.msg-text');
|
||||
if (!textDiv) { textDiv = bubble; }
|
||||
textDiv.innerHTML = renderMarkdown(pendingText);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
381
public/style.css
381
public/style.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
414
server.js
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user