feat(cli): implement storage management for CLI Web

CLI Web's Settings > Storage Management was completely non-functional
(stub cacheApi returning empty data). This adds real HTTP endpoints
and wires the frontend to use them.

Backend (cache.ts):
- GET /_web/cache/info — scan databases/ai/cache/logs directory sizes
- POST /_web/cache/clear — clear cache or logs directory (allowlist)
- POST /_web/cache/open-dir — open directory in system file manager
- GET /_web/cache/data-dir — return current data directory path
- GET /_web/cache/latest-import-log — find latest import log file

Frontend:
- Replace cacheApi stubs with real HTTP calls in registry.ts
- Hide data directory migration UI in CLI Web (IS_ELECTRON guard)
- Add "Open Root Dir" button for ~/.chatlab/ in header area

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor authored and digua committed 2026-05-26 23:19:47 +08:00
1 parent 9e88dcbd09
commit 8819dff62c
8 files changed
+362 -108

No files matched your search

+184 -1
View File
@@ -1,8 +1,191 @@
import * as fs from 'fs'
import * as fsp from 'fs/promises'
import * as path from 'path'
import type { FastifyInstance } from 'fastify'
import type { PathProvider } from '@openchatlab/core'
import { loadConfig } from '@openchatlab/config'
async function getDirSize(dirPath: string): Promise<number> {
let totalSize = 0
try {
if (!fs.existsSync(dirPath)) return 0
const files = await fsp.readdir(dirPath, { withFileTypes: true })
for (const file of files) {
const filePath = path.join(dirPath, file.name)
if (file.isDirectory()) {
totalSize += await getDirSize(filePath)
} else {
const stat = await fsp.stat(filePath)
totalSize += stat.size
}
}
} catch {
// directory inaccessible
}
return totalSize
}
async function getFileCount(dirPath: string): Promise<number> {
let count = 0
try {
if (!fs.existsSync(dirPath)) return 0
const files = await fsp.readdir(dirPath, { withFileTypes: true })
for (const file of files) {
const filePath = path.join(dirPath, file.name)
if (file.isDirectory()) {
count += await getFileCount(filePath)
} else {
count++
}
}
} catch {
// directory inaccessible
}
return count
}
export function registerCacheRoutes(server: FastifyInstance, pathProvider: PathProvider): void {
const downloadsDir = pathProvider.getDownloadsDir()
// --- Storage management endpoints ---
server.get('/_web/cache/info', async () => {
const directories = [
{
id: 'databases',
name: 'settings.storage.cache.databases.name',
description: 'settings.storage.cache.databases.description',
path: pathProvider.getDatabaseDir(),
icon: 'i-heroicons-circle-stack',
canClear: false,
},
{
id: 'ai',
name: 'settings.storage.cache.ai.name',
description: 'settings.storage.cache.ai.description',
path: pathProvider.getAiDataDir(),
icon: 'i-heroicons-sparkles',
canClear: false,
},
{
id: 'cache',
name: 'settings.storage.cache.statsCache.name',
description: 'settings.storage.cache.statsCache.description',
path: pathProvider.getCacheDir(),
icon: 'i-heroicons-bolt',
canClear: true,
},
{
id: 'logs',
name: 'settings.storage.cache.logs.name',
description: 'settings.storage.cache.logs.description',
path: pathProvider.getLogsDir(),
icon: 'i-heroicons-document-text',
canClear: true,
},
]
const results = await Promise.all(
directories.map(async (dir) => {
const [size, fileCount] = await Promise.all([getDirSize(dir.path), getFileCount(dir.path)])
return { ...dir, size, fileCount, exists: fs.existsSync(dir.path) }
})
)
return {
baseDir: pathProvider.getUserDataDir(),
systemDir: pathProvider.getSystemDir(),
directories: results,
totalSize: results.reduce((sum, d) => sum + d.size, 0),
}
})
server.post<{ Body: { cacheId: string } }>('/_web/cache/clear', async (request) => {
const { cacheId } = request.body
const allowedDirs: Record<string, string> = {
cache: pathProvider.getCacheDir(),
logs: pathProvider.getLogsDir(),
}
const dirPath = allowedDirs[cacheId]
if (!dirPath) return { success: false, error: 'Not allowed to clear this directory' }
if (!fs.existsSync(dirPath)) return { success: true }
const files = await fsp.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stat = await fsp.stat(filePath)
if (stat.isDirectory()) {
await fsp.rm(filePath, { recursive: true })
} else {
await fsp.unlink(filePath)
}
}
return { success: true }
})
server.post<{ Body: { cacheId: string } }>('/_web/cache/open-dir', async (request) => {
const { cacheId } = request.body
const dirPaths: Record<string, string> = {
base: pathProvider.getUserDataDir(),
system: pathProvider.getSystemDir(),
databases: pathProvider.getDatabaseDir(),
cache: pathProvider.getCacheDir(),
ai: pathProvider.getAiDataDir(),
logs: pathProvider.getLogsDir(),
downloads: downloadsDir,
}
const dirPath = dirPaths[cacheId]
if (!dirPath) return { success: false, error: 'Unknown directory' }
if (!fs.existsSync(dirPath)) {
await fsp.mkdir(dirPath, { recursive: true })
}
const { exec } = await import('child_process')
const platform = process.platform
if (platform === 'darwin') {
exec(`open "${dirPath}"`)
} else if (platform === 'win32') {
exec(`explorer "${dirPath}"`)
} else {
exec(`xdg-open "${dirPath}"`)
}
return { success: true }
})
server.get('/_web/cache/data-dir', async () => {
const config = loadConfig()
const isCustom = Boolean(config.data.user_data_dir || process.env.CHATLAB_DATA_DIR)
return { path: pathProvider.getUserDataDir(), isCustom }
})
server.get('/_web/cache/latest-import-log', async () => {
const importLogDir = path.join(pathProvider.getLogsDir(), 'import')
if (!fs.existsSync(importLogDir)) {
return { success: false, error: 'Log directory not found' }
}
const files = await fsp.readdir(importLogDir)
const logFiles = files.filter((f) => f.startsWith('import_') && f.endsWith('.log'))
if (logFiles.length === 0) {
return { success: false, error: 'No import logs found' }
}
const fileStats = await Promise.all(
logFiles.map(async (f) => {
const filePath = path.join(importLogDir, f)
const stat = await fsp.stat(filePath)
return { name: f, path: filePath, mtime: stat.mtime.getTime() }
})
)
fileStats.sort((a, b) => b.mtime - a.mtime)
return { success: true, path: fileStats[0].path, name: fileStats[0].name }
})
// --- Existing endpoints ---
export function registerCacheRoutes(server: FastifyInstance, downloadsDir: string): void {
server.post<{
Body: { filename: string; dataUrl: string }
}>('/_web/cache/save-to-downloads', async (request) => {
+16 -4
View File
@@ -14,12 +14,13 @@
* import – File / directory / incremental import + demo
* merge – Merge parse / conflicts / execute
* export – Markdown export
* cache – Save to downloads + show in folder
* cache – Storage management + save to downloads + show in folder
*/
import * as os from 'os'
import * as path from 'path'
import type { FastifyInstance } from 'fastify'
import type { PathProvider } from '@openchatlab/core'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import { createDatabaseManagerAdapter } from '@openchatlab/node-runtime'
import { MergeSessionCache } from '../../../merger/merge-cache'
@@ -37,7 +38,7 @@ import { registerCacheRoutes } from './cache'
export function registerWebRoutes(
server: FastifyInstance,
dbManager: DatabaseManager,
options?: { pathProvider?: import('@openchatlab/core').PathProvider; nativeBinding?: string }
options?: { pathProvider?: PathProvider; nativeBinding?: string }
): void {
const adapter = createDatabaseManagerAdapter(dbManager)
@@ -46,7 +47,18 @@ export function registerWebRoutes(
: null
mergeCache?.cleanupOrphans()
const downloadsDir = options?.pathProvider?.getDownloadsDir() || path.join(os.homedir(), 'Downloads')
const fallbackPathProvider: PathProvider = {
getSystemDir: () => path.join(os.homedir(), '.chatlab'),
getUserDataDir: () => path.join(os.homedir(), '.chatlab', 'data'),
getDatabaseDir: () => path.join(os.homedir(), '.chatlab', 'data', 'databases'),
getAiDataDir: () => path.join(os.homedir(), '.chatlab', 'ai'),
getSettingsDir: () => path.join(os.homedir(), '.chatlab', 'settings'),
getCacheDir: () => path.join(os.homedir(), '.chatlab', 'cache'),
getTempDir: () => path.join(os.homedir(), '.chatlab', 'temp'),
getLogsDir: () => path.join(os.homedir(), '.chatlab', 'logs'),
getDownloadsDir: () => path.join(os.homedir(), 'Downloads'),
}
const resolvedPathProvider = options?.pathProvider ?? fallbackPathProvider
registerSessionRoutes(server, adapter)
registerMemberRoutes(server, adapter)
@@ -59,5 +71,5 @@ export function registerWebRoutes(
registerMergeRoutes(server, dbManager, mergeCache)
}
registerExportRoutes(server, adapter)
registerCacheRoutes(server, downloadsDir)
registerCacheRoutes(server, resolvedPathProvider)
}