From 8819dff62c960e066e114f44af57d303fca7cf13 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 25 May 2026 19:15:53 +0800 Subject: [PATCH] feat(cli): implement storage management for CLI Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/cli/src/http/routes/web/cache.ts | 185 +++++++++++++++- apps/cli/src/http/routes/web/index.ts | 20 +- .../DataStorage/StorageManageSection.vue | 208 +++++++++--------- src/i18n/locales/en-US/settings.json | 1 + src/i18n/locales/ja-JP/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 + src/services/registry.ts | 53 ++++- 8 files changed, 362 insertions(+), 108 deletions(-) diff --git a/apps/cli/src/http/routes/web/cache.ts b/apps/cli/src/http/routes/web/cache.ts index 55a663e7..cffe81d2 100644 --- a/apps/cli/src/http/routes/web/cache.ts +++ b/apps/cli/src/http/routes/web/cache.ts @@ -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 { + 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 { + 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 = { + 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 = { + 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) => { diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 483ad191..482332f9 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -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) } diff --git a/src/components/common/Settings/DataStorage/StorageManageSection.vue b/src/components/common/Settings/DataStorage/StorageManageSection.vue index 6c764bfc..af89be20 100644 --- a/src/components/common/Settings/DataStorage/StorageManageSection.vue +++ b/src/components/common/Settings/DataStorage/StorageManageSection.vue @@ -6,6 +6,7 @@ import { ref, onMounted, computed } from 'vue' import { useI18n } from 'vue-i18n' import { usePlatformService } from '@/services' +import { IS_ELECTRON } from '@/utils/platform' const { t } = useI18n() @@ -24,6 +25,7 @@ interface CacheDirectoryInfo { interface CacheInfo { baseDir: string + systemDir?: string directories: CacheDirectoryInfo[] totalSize: number } @@ -182,10 +184,9 @@ async function relaunchApp() { await usePlatformService().relaunch() } -// 组件挂载时加载数据 onMounted(() => { loadCacheInfo() - loadDataDir() + if (IS_ELECTRON) loadDataDir() }) // 暴露刷新方法 @@ -206,6 +207,15 @@ defineExpose({

{{ t('settings.storage.description') }}

+ + {{ t('settings.storage.openRootDir') }} +
{{ t('settings.storage.totalUsage') }} @@ -295,116 +305,118 @@ defineExpose({
- -
-
-
-

- {{ t('settings.storage.dataLocation.title') }} -

-

- {{ t('settings.storage.dataLocation.description') }} -

+ +
diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 98bd8f6a..c0119ff5 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -222,6 +222,7 @@ "migrationSuccessMessage": "The app needs to restart for changes to take effect.", "relaunchNow": "Restart Now" }, + "openRootDir": "Open Root", "totalUsage": "Total: ", "loading": "Loading...", "files": "files", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 8b762c01..75be7214 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -222,6 +222,7 @@ "migrationSuccessMessage": "変更を反映するにはアプリの再起動が必要です。", "relaunchNow": "今すぐ再起動" }, + "openRootDir": "ルートを開く", "totalUsage": "合計使用量:", "loading": "読み込み中...", "files": "ファイル", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 95315dca..8ea901df 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -222,6 +222,7 @@ "migrationSuccessMessage": "应用需要重启以使更改生效。", "relaunchNow": "立即重启" }, + "openRootDir": "打开根目录", "totalUsage": "总占用:", "loading": "加载中...", "files": "文件", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index a7aaa90a..f804cf9c 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -222,6 +222,7 @@ "migrationSuccessMessage": "應用程式需要重新啟動以使變更生效。", "relaunchNow": "立即重新啟動" }, + "openRootDir": "開啟根目錄", "totalUsage": "總佔用:", "loading": "載入中...", "files": "檔案", diff --git a/src/services/registry.ts b/src/services/registry.ts index 0876a10e..c5490f0d 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -433,11 +433,54 @@ async function installAiApiShims(): Promise { return { success: false, error: error instanceof Error ? error.message : String(error) } } }, - getInfo: () => Promise.resolve({ success: true, caches: [] }), - clear: () => Promise.resolve({ success: true }), - openDir: () => Promise.resolve({ success: true }), - getLatestImportLog: () => Promise.resolve({ success: true }), - getDataDir: () => Promise.resolve({ success: true, path: '' }), + getInfo: async () => { + try { + const resp = await fetch('/_web/cache/info') + return await resp.json() + } catch (error) { + return { baseDir: '', directories: [], totalSize: 0 } + } + }, + clear: async (cacheId: string) => { + try { + const resp = await fetch('/_web/cache/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cacheId }), + }) + return await resp.json() + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + openDir: async (cacheId: string) => { + try { + const resp = await fetch('/_web/cache/open-dir', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cacheId }), + }) + return await resp.json() + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + getLatestImportLog: async () => { + try { + const resp = await fetch('/_web/cache/latest-import-log') + return await resp.json() + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + getDataDir: async () => { + try { + const resp = await fetch('/_web/cache/data-dir') + return await resp.json() + } catch (error) { + return { path: '', isCustom: false } + } + }, } }