feat: update session workspace flow and web ui
This commit is contained in:
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