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

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}`);
});