From e68ce7726678a0db7945144f82bd08c825d9dee8 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 2 Jun 2026 22:32:21 +0800 Subject: [PATCH] feat(data): support CLI web data directory migration --- apps/cli/src/cli.ts | 12 +- apps/cli/src/http/index.ts | 13 +- apps/cli/src/http/routes/web/index.ts | 26 +- apps/desktop/main/utils/dataDirSwitch.ts | 152 +------- packages/http-routes/src/context.ts | 5 + .../http-routes/src/routes/web/cache.test.ts | 114 ++++++ packages/http-routes/src/routes/web/cache.ts | 19 + .../node-runtime/src/data-dir-switch.test.ts | 87 ++++- packages/node-runtime/src/data-dir-switch.ts | 364 ++++++++++++++++++ packages/node-runtime/src/index.ts | 27 +- .../node-runtime/src/node-path-provider.ts | 10 +- .../DataStorage/StorageManageSection.vue | 80 +++- src/i18n/locales/en-US/settings.json | 10 +- src/i18n/locales/ja-JP/settings.json | 10 +- src/i18n/locales/zh-CN/settings.json | 10 +- src/i18n/locales/zh-TW/settings.json | 10 +- src/services/cache/fetch.ts | 13 + src/services/cache/types.ts | 10 + 18 files changed, 801 insertions(+), 171 deletions(-) create mode 100644 packages/http-routes/src/routes/web/cache.test.ts rename apps/desktop/main/utils/dataDirSwitch.test.ts => packages/node-runtime/src/data-dir-switch.test.ts (54%) create mode 100644 packages/node-runtime/src/data-dir-switch.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 57ac8eaa..f6b840e2 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -11,6 +11,7 @@ import { DEFAULT_API_PORT, loadConfig, getConfigPath } from '@openchatlab/config import { NodePathProvider, DatabaseManager, + applyPendingNodeDataDirMigrationIfNeeded, hasPendingElectronDataWarning, verifyCliDataPath, } from '@openchatlab/node-runtime' @@ -450,7 +451,16 @@ function resolveNativeBinding(): string | undefined { } function initRuntime() { - const config = loadConfig() + let config = loadConfig() + const pendingMigration = applyPendingNodeDataDirMigrationIfNeeded() + if (!pendingMigration.skipped) { + if (pendingMigration.success) { + console.log('[Migration] Pending data directory migration completed') + config = loadConfig() + } else { + console.error('[Migration] Pending data directory migration failed:', pendingMigration.error) + } + } const userDataDir = config.data.user_data_dir || undefined const pathProvider = new NodePathProvider(userDataDir) pathProvider.ensureAllDirs() diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index ea5ee0d8..881303d2 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -17,6 +17,7 @@ import { LLMConfigStore, CustomProviderStore, CustomModelStore, + applyPendingNodeDataDirMigrationIfNeeded, hasPendingElectronDataWarning, verifyCliDataPath, } from '@openchatlab/node-runtime' @@ -93,11 +94,21 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ throw new Error('HTTP server is already running') } - const config = loadConfig() + let config = loadConfig() const port = options?.port ?? config.api.port const host = options?.host ?? config.api.host const token = options?.token ?? ensureToken(config) + const pendingMigration = applyPendingNodeDataDirMigrationIfNeeded() + if (!pendingMigration.skipped) { + if (pendingMigration.success) { + console.log('[Migration] Pending data directory migration completed') + config = loadConfig() + } else { + console.error('[Migration] Pending data directory migration failed:', pendingMigration.error) + } + } + const userDataDir = config.data.user_data_dir || undefined const pathProvider = new NodePathProvider(userDataDir) pathProvider.ensureAllDirs() diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 7540d20a..be21d33c 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -29,15 +29,20 @@ import type { LLMConfigStore, CustomProviderStore, CustomModelStore, + PendingDataDirMigration, +} from '@openchatlab/node-runtime' +import { + createDatabaseManagerAdapter, + createNodeDataDirSwitch, + getDefaultNodeUserDataDir, + getPendingNodeDataDirMigration, } from '@openchatlab/node-runtime' -import { createDatabaseManagerAdapter } from '@openchatlab/node-runtime' import { registerSharedRoutes } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes' import { MergeSessionCache } from '../../../merger/merge-cache' import { registerImportRoutes } from './import' import { openDirectoryPath, showPathInFolder } from './cache' import { getVersion } from '../../../version' -import { loadConfig } from '@openchatlab/config' export interface AiContextOptions { aiDataDir: string @@ -105,8 +110,8 @@ export function registerWebRoutes( return { sessionId: result.sessionId } } - const cliConfig = loadConfig() - const isCustom = Boolean(cliConfig.data.user_data_dir || process.env.CHATLAB_DATA_DIR) + const defaultUserDataDir = getDefaultNodeUserDataDir() + const isCustom = path.resolve(resolvedPathProvider.getUserDataDir()) !== path.resolve(defaultUserDataDir) registerSharedRoutes( server, @@ -119,7 +124,20 @@ export function registerWebRoutes( openDirectory: openDirectoryPath, showInFolder: showPathInFolder, downloadsDir: resolvedPathProvider.getDownloadsDir(), + defaultUserDataDir, isCustomDataDir: isCustom, + canSetDataDir: !process.env.CHATLAB_DATA_DIR, + getPendingDataDirMigration: (): PendingDataDirMigration | null => + getPendingNodeDataDirMigration(resolvedPathProvider.getSystemDir()), + setDataDir: (dirPath, migrate) => + createNodeDataDirSwitch({ + systemDir: resolvedPathProvider.getSystemDir(), + currentDir: resolvedPathProvider.getUserDataDir(), + targetDir: dirPath, + defaultDir: defaultUserDataDir, + migrate, + envDataDir: process.env.CHATLAB_DATA_DIR, + }), ...(mergeCache && { mergeSessionCache: mergeCache, streamImport: cliStreamImport, diff --git a/apps/desktop/main/utils/dataDirSwitch.ts b/apps/desktop/main/utils/dataDirSwitch.ts index 4413b1a5..41b69d6e 100644 --- a/apps/desktop/main/utils/dataDirSwitch.ts +++ b/apps/desktop/main/utils/dataDirSwitch.ts @@ -1,144 +1,8 @@ -import * as fs from 'fs' -import * as path from 'path' -import { copyDirMerge, type CopyStats } from './pathUtils' - -const CHATLAB_MARKER_FILE = '.chatlab' -const USER_DATA_REQUIRED_DIRS = ['databases'] - -export interface PendingDataDirMigration { - from: string - to: string - migrate: boolean - deleteSourceOnSuccess: boolean - createdAt: string -} - -export interface RunPendingDataDirMigrationDeps { - copyDirMerge?: typeof copyDirMerge - ensureDir?: (dirPath: string) => void - writeUserDataDir: (dir: string) => void - clearPendingMigration: () => void - markPendingDeleteDir?: (dir: string) => void - log?: (message: string) => void -} - -export interface RunPendingDataDirMigrationResult { - success: boolean - from: string - to: string - copied: number - skipped: number - errors: string[] -} - -function ensureDir(dirPath: string): void { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }) - } -} - -function hasChatLabUserDataStructure(entries: string[]): boolean { - return entries.includes(CHATLAB_MARKER_FILE) && USER_DATA_REQUIRED_DIRS.every((dir) => entries.includes(dir)) -} - -export function isExistingUserDataDir(dirPath: string): boolean { - if (!fs.existsSync(dirPath)) return false - - try { - return hasChatLabUserDataStructure(fs.readdirSync(dirPath)) - } catch { - return false - } -} - -export function isUserDataDirSafeToUse(dirPath: string): boolean { - if (!fs.existsSync(dirPath)) return true - - try { - const entries = fs.readdirSync(dirPath) - if (entries.length === 0) return true - return hasChatLabUserDataStructure(entries) - } catch { - return false - } -} - -export function isDirectoryEmptyOrMissing(dirPath: string): boolean { - if (!fs.existsSync(dirPath)) return true - - try { - return fs.readdirSync(dirPath).length === 0 - } catch { - return false - } -} - -export function createPendingDataDirMigration(input: { - from: string - to: string - migrate: boolean - targetWasEmpty: boolean -}): PendingDataDirMigration { - return { - from: input.from, - to: input.to, - migrate: input.migrate, - deleteSourceOnSuccess: input.migrate && input.targetWasEmpty, - createdAt: new Date().toISOString(), - } -} - -export function runPendingDataDirMigration( - pending: PendingDataDirMigration, - deps: RunPendingDataDirMigrationDeps -): RunPendingDataDirMigrationResult { - const copy = deps.copyDirMerge ?? copyDirMerge - const mkdir = deps.ensureDir ?? ensureDir - - let stats: CopyStats = { copied: 0, skipped: 0, errors: [] } - if (pending.migrate && path.resolve(pending.from) !== path.resolve(pending.to)) { - if (!fs.existsSync(pending.from)) { - return { - success: false, - from: pending.from, - to: pending.to, - copied: 0, - skipped: 0, - errors: [`源数据目录不存在: ${pending.from}`], - } - } - - stats = copy(pending.from, pending.to, mkdir) - deps.log?.( - `数据目录迁移完成: 从 ${pending.from} 到 ${pending.to},复制 ${stats.copied} 项,跳过 ${stats.skipped} 项,错误 ${stats.errors.length} 项` - ) - if (stats.errors.length > 0) { - return { - success: false, - from: pending.from, - to: pending.to, - copied: stats.copied, - skipped: stats.skipped, - errors: stats.errors, - } - } - } else { - mkdir(pending.to) - } - - deps.writeUserDataDir(pending.to) - deps.clearPendingMigration() - - if (pending.deleteSourceOnSuccess && path.resolve(pending.from) !== path.resolve(pending.to)) { - deps.markPendingDeleteDir?.(pending.from) - } - - return { - success: true, - from: pending.from, - to: pending.to, - copied: stats.copied, - skipped: stats.skipped, - errors: [], - } -} +export { + createPendingDataDirMigration, + isDirectoryEmptyOrMissing, + isExistingUserDataDir, + isUserDataDirSafeToUse, + runPendingDataDirMigration, +} from '@openchatlab/node-runtime' +export type { PendingDataDirMigration } from '@openchatlab/node-runtime' diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index ec8ed94b..5e6d772a 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -9,6 +9,8 @@ import type { PathProvider } from '@openchatlab/core' import type { DatabaseManager, + DataDirSwitchResult, + PendingDataDirMigration, SessionRuntimeAdapter, PreferencesManager, AIConversationManager, @@ -56,6 +58,9 @@ export interface HttpRouteContext { downloadsDir?: string defaultUserDataDir?: string isCustomDataDir?: boolean + canSetDataDir?: boolean + getPendingDataDirMigration?: () => PendingDataDirMigration | null + setDataDir?: (dirPath: string | null, migrate?: boolean) => Promise | DataDirSwitchResult /** Agent streaming — platform-specific execution (optional) */ runAgentStream?: ( diff --git a/packages/http-routes/src/routes/web/cache.test.ts b/packages/http-routes/src/routes/web/cache.test.ts new file mode 100644 index 00000000..fb8f7160 --- /dev/null +++ b/packages/http-routes/src/routes/web/cache.test.ts @@ -0,0 +1,114 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import type { HttpRouteContext } from '../../context' +import { registerCacheRoutes } from './cache' + +function createPathProvider(): PathProvider { + return { + getSystemDir: () => '/tmp/chatlab-test', + getUserDataDir: () => '/tmp/chatlab-test/data', + getDatabaseDir: () => '/tmp/chatlab-test/databases', + getAiDataDir: () => '/tmp/chatlab-test/ai', + getSettingsDir: () => '/tmp/chatlab-test/settings', + getCacheDir: () => '/tmp/chatlab-test/cache', + getTempDir: () => '/tmp/chatlab-test/temp', + getLogsDir: () => '/tmp/chatlab-test/logs', + getDownloadsDir: () => '/tmp/chatlab-test/downloads', + } +} + +describe('registerCacheRoutes data directory routes', () => { + it('returns data directory capability and pending migration', async () => { + const app = Fastify() + const ctx = { + pathProvider: createPathProvider(), + defaultUserDataDir: '/tmp/chatlab-test/default-data', + isCustomDataDir: true, + canSetDataDir: true, + getPendingDataDirMigration: () => ({ + from: '/tmp/chatlab-test/data', + to: '/tmp/chatlab-test/new-data', + migrate: true, + createdAt: '2026-06-02T00:00:00.000Z', + }), + } as unknown as HttpRouteContext + + registerCacheRoutes(app, ctx) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/_web/cache/data-dir' }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + path: '/tmp/chatlab-test/data', + defaultPath: '/tmp/chatlab-test/default-data', + isCustom: true, + canSetDataDir: true, + pendingMigration: { + from: '/tmp/chatlab-test/data', + to: '/tmp/chatlab-test/new-data', + createdAt: '2026-06-02T00:00:00.000Z', + }, + }) + + await app.close() + }) + + it('delegates data directory changes to context callback', async () => { + const app = Fastify() + const calls: Array<{ path: string | null; migrate?: boolean }> = [] + const ctx = { + pathProvider: createPathProvider(), + setDataDir: (dirPath: string | null, migrate?: boolean) => { + calls.push({ path: dirPath, migrate }) + return { + success: true, + from: '/tmp/chatlab-test/data', + to: dirPath ?? '/tmp/chatlab-test/default-data', + requiresRelaunch: true, + } + }, + } as unknown as HttpRouteContext + + registerCacheRoutes(app, ctx) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/data-dir', + payload: { path: '/tmp/chatlab-test/new-data', migrate: true }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(calls, [{ path: '/tmp/chatlab-test/new-data', migrate: true }]) + assert.deepEqual(response.json(), { + success: true, + from: '/tmp/chatlab-test/data', + to: '/tmp/chatlab-test/new-data', + requiresRelaunch: true, + }) + + await app.close() + }) + + it('returns 501 when data directory changes are unsupported', async () => { + const app = Fastify() + registerCacheRoutes(app, { pathProvider: createPathProvider() } as unknown as HttpRouteContext) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/data-dir', + payload: { path: '/tmp/chatlab-test/new-data', migrate: true }, + }) + + assert.equal(response.statusCode, 501) + assert.deepEqual(response.json(), { + success: false, + error: 'Data directory changes are not supported', + }) + + await app.close() + }) +}) diff --git a/packages/http-routes/src/routes/web/cache.ts b/packages/http-routes/src/routes/web/cache.ts index 39c53e77..24c62dcc 100644 --- a/packages/http-routes/src/routes/web/cache.ts +++ b/packages/http-routes/src/routes/web/cache.ts @@ -122,13 +122,32 @@ export function registerCacheRoutes(server: FastifyInstance, ctx: HttpRouteConte }) server.get('/_web/cache/data-dir', async () => { + const pending = ctx.getPendingDataDirMigration?.() return { path: pp.getUserDataDir(), defaultPath: ctx.defaultUserDataDir, isCustom: ctx.isCustomDataDir ?? false, + canSetDataDir: ctx.canSetDataDir ?? Boolean(ctx.setDataDir), + pendingMigration: pending + ? { + from: pending.from, + to: pending.to, + createdAt: pending.createdAt, + } + : undefined, } }) + server.post<{ Body: { path?: string | null; migrate?: boolean } }>('/_web/cache/data-dir', async (request, reply) => { + if (!ctx.setDataDir) { + return reply.code(501).send({ success: false, error: 'Data directory changes are not supported' }) + } + + const targetPath = typeof request.body?.path === 'string' ? request.body.path : null + const migrate = request.body?.migrate !== false + return ctx.setDataDir(targetPath, migrate) + }) + server.get('/_web/cache/latest-import-log', async () => { const importLogDir = path.join(pp.getLogsDir(), 'import') if (!fs.existsSync(importLogDir)) { diff --git a/apps/desktop/main/utils/dataDirSwitch.test.ts b/packages/node-runtime/src/data-dir-switch.test.ts similarity index 54% rename from apps/desktop/main/utils/dataDirSwitch.test.ts rename to packages/node-runtime/src/data-dir-switch.test.ts index c56f0cd5..d531db71 100644 --- a/apps/desktop/main/utils/dataDirSwitch.test.ts +++ b/packages/node-runtime/src/data-dir-switch.test.ts @@ -3,10 +3,19 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { createPendingDataDirMigration, runPendingDataDirMigration, isExistingUserDataDir } from './dataDirSwitch' +import { + applyPendingNodeDataDirMigration, + createPendingDataDirMigration, + createNodeDataDirSwitch, + getPendingNodeDataDirMigration, + isExistingUserDataDir, + runPendingDataDirMigration, +} from './data-dir-switch' +import { applyPendingNodeDataDirMigrationIfNeeded } from './node-path-provider' function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-data-switch-')) + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-switch-')) } function writeFile(filePath: string, content = 'data'): void { @@ -125,3 +134,77 @@ test('isExistingUserDataDir accepts current user data layout without settings di assert.equal(isExistingUserDataDir(dataDir), true) }) + +test('createNodeDataDirSwitch writes pending migration under the system settings directory', () => { + const root = makeTempDir() + const systemDir = path.join(root, 'system') + const currentDir = path.join(root, 'current') + const targetDir = path.join(root, 'target') + writeFile(path.join(currentDir, 'databases', 'session.db'), 'sqlite') + + const result = createNodeDataDirSwitch({ systemDir, currentDir, targetDir, migrate: true }) + const pending = getPendingNodeDataDirMigration(systemDir) + + assert.equal(result.success, true) + assert.equal(result.requiresRelaunch, true) + assert.equal(pending?.from, currentDir) + assert.equal(pending?.to, targetDir) +}) + +test('applyPendingNodeDataDirMigration deletes old data directory after successful migration to empty target', () => { + const root = makeTempDir() + const systemDir = path.join(root, 'system') + const currentDir = path.join(root, 'current') + const targetDir = path.join(root, 'target') + writeFile(path.join(currentDir, '.chatlab'), 'ChatLab Data Directory') + writeFile(path.join(currentDir, 'databases', 'session.db'), 'sqlite') + + const switchResult = createNodeDataDirSwitch({ systemDir, currentDir, targetDir, migrate: true }) + assert.equal(switchResult.success, true) + + const writes: Array<{ section: string; key: string; value: unknown }> = [] + const result = applyPendingNodeDataDirMigration(systemDir, { + writeConfigField(section, key, value) { + writes.push({ section, key, value }) + }, + }) + + assert.equal(result.success, true) + assert.equal(fs.existsSync(currentDir), false) + assert.equal(fs.readFileSync(path.join(targetDir, 'databases', 'session.db'), 'utf-8'), 'sqlite') + assert.equal(getPendingNodeDataDirMigration(systemDir), null) + assert.deepEqual(writes, [ + { section: 'data', key: 'user_data_dir', value: targetDir }, + { section: 'data', key: 'electron_migration_done', value: true }, + ]) +}) + +test('createNodeDataDirSwitch rejects data directory changes while CHATLAB_DATA_DIR is active', () => { + const root = makeTempDir() + const result = createNodeDataDirSwitch({ + systemDir: path.join(root, 'system'), + currentDir: path.join(root, 'current'), + targetDir: path.join(root, 'target'), + migrate: true, + envDataDir: '/env/data', + }) + + assert.equal(result.success, false) +}) + +test('applyPendingNodeDataDirMigrationIfNeeded skips while CHATLAB_DATA_DIR is active', () => { + const originalEnvDir = process.env.CHATLAB_DATA_DIR + process.env.CHATLAB_DATA_DIR = '/env/data' + + try { + const result = applyPendingNodeDataDirMigrationIfNeeded() + assert.equal(result.success, true) + assert.equal(result.skipped, true) + } finally { + if (originalEnvDir === undefined) { + delete process.env.CHATLAB_DATA_DIR + } else { + process.env.CHATLAB_DATA_DIR = originalEnvDir + } + } +}) diff --git a/packages/node-runtime/src/data-dir-switch.ts b/packages/node-runtime/src/data-dir-switch.ts new file mode 100644 index 00000000..94e03d20 --- /dev/null +++ b/packages/node-runtime/src/data-dir-switch.ts @@ -0,0 +1,364 @@ +import * as fs from 'fs' +import * as path from 'path' +import { writeConfigField } from '@openchatlab/config' + +const CHATLAB_MARKER_FILE = '.chatlab' +const USER_DATA_REQUIRED_DIRS = ['databases'] +const PENDING_MIGRATION_FILE = 'data-dir-migration.json' + +const DANGEROUS_PATHS = [ + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\ProgramData', + '/usr', + '/etc', + '/bin', + '/sbin', + '/lib', + '/var', + '/boot', + '/root', + '/System', + '/Library', +] + +export interface CopyStats { + copied: number + skipped: number + errors: string[] +} + +export interface PendingDataDirMigration { + from: string + to: string + migrate: boolean + deleteSourceOnSuccess: boolean + createdAt: string +} + +export interface RunPendingDataDirMigrationDeps { + copyDirMerge?: typeof copyDirMerge + ensureDir?: (dirPath: string) => void + writeUserDataDir: (dir: string) => void + clearPendingMigration: () => void + markPendingDeleteDir?: (dir: string) => void + log?: (message: string) => void +} + +export interface RunPendingDataDirMigrationResult { + success: boolean + from: string + to: string + copied: number + skipped: number + errors: string[] +} + +export interface DataDirSwitchResult { + success: boolean + error?: string + from?: string + to?: string + requiresRelaunch?: boolean +} + +export interface ApplyPendingNodeDataDirMigrationDeps { + writeConfigField?: typeof writeConfigField + removeDir?: (dir: string) => void +} + +function normalizePathForCompare(input: string): string { + const resolved = path.resolve(input) + const normalized = path.normalize(resolved) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function isSubPath(parent: string, child: string): boolean { + const parentPath = normalizePathForCompare(parent) + const childPath = normalizePathForCompare(child) + + if (parentPath === childPath) return false + return childPath.startsWith(`${parentPath}${path.sep}`) +} + +function isPathSafe(targetPath: string): boolean { + const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\') + + for (const dangerous of DANGEROUS_PATHS) { + const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\') + if (normalizedTarget.startsWith(normalizedDangerous)) { + return false + } + } + + return true +} + +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } +} + +function hasChatLabUserDataStructure(entries: string[]): boolean { + return entries.includes(CHATLAB_MARKER_FILE) && USER_DATA_REQUIRED_DIRS.every((dir) => entries.includes(dir)) +} + +export function isExistingUserDataDir(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return false + + try { + return hasChatLabUserDataStructure(fs.readdirSync(dirPath)) + } catch { + return false + } +} + +export function isUserDataDirSafeToUse(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return true + + try { + const entries = fs.readdirSync(dirPath) + if (entries.length === 0) return true + return hasChatLabUserDataStructure(entries) + } catch { + return false + } +} + +export function isDirectoryEmptyOrMissing(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return true + + try { + return fs.readdirSync(dirPath).length === 0 + } catch { + return false + } +} + +export function copyDirMerge( + src: string, + dest: string, + mkdir: (dirPath: string) => void = ensureDir, + stats: CopyStats = { copied: 0, skipped: 0, errors: [] } +): CopyStats { + if (!fs.existsSync(src)) return stats + + try { + mkdir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + try { + if (entry.isDirectory()) { + copyDirMerge(srcPath, destPath, mkdir, stats) + } else if (!fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath) + stats.copied++ + } else { + stats.skipped++ + } + } catch (error) { + stats.errors.push(`复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`) + } + } + } catch (error) { + stats.errors.push(`读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`) + } + + return stats +} + +export function createPendingDataDirMigration(input: { + from: string + to: string + migrate: boolean + targetWasEmpty: boolean +}): PendingDataDirMigration { + return { + from: input.from, + to: input.to, + migrate: input.migrate, + deleteSourceOnSuccess: input.migrate && input.targetWasEmpty, + createdAt: new Date().toISOString(), + } +} + +export function runPendingDataDirMigration( + pending: PendingDataDirMigration, + deps: RunPendingDataDirMigrationDeps +): RunPendingDataDirMigrationResult { + const copy = deps.copyDirMerge ?? copyDirMerge + const mkdir = deps.ensureDir ?? ensureDir + + let stats: CopyStats = { copied: 0, skipped: 0, errors: [] } + if (pending.migrate && path.resolve(pending.from) !== path.resolve(pending.to)) { + if (!fs.existsSync(pending.from)) { + return { + success: false, + from: pending.from, + to: pending.to, + copied: 0, + skipped: 0, + errors: [`源数据目录不存在: ${pending.from}`], + } + } + + stats = copy(pending.from, pending.to, mkdir) + deps.log?.( + `数据目录迁移完成: 从 ${pending.from} 到 ${pending.to},复制 ${stats.copied} 项,跳过 ${stats.skipped} 项,错误 ${stats.errors.length} 项` + ) + if (stats.errors.length > 0) { + return { + success: false, + from: pending.from, + to: pending.to, + copied: stats.copied, + skipped: stats.skipped, + errors: stats.errors, + } + } + } else { + mkdir(pending.to) + } + + deps.writeUserDataDir(pending.to) + deps.clearPendingMigration() + + if (pending.deleteSourceOnSuccess && path.resolve(pending.from) !== path.resolve(pending.to)) { + deps.markPendingDeleteDir?.(pending.from) + } + + return { + success: true, + from: pending.from, + to: pending.to, + copied: stats.copied, + skipped: stats.skipped, + errors: [], + } +} + +function getPendingMigrationPath(systemDir: string): string { + return path.join(systemDir, 'settings', PENDING_MIGRATION_FILE) +} + +export function getPendingNodeDataDirMigration(systemDir: string): PendingDataDirMigration | null { + const filePath = getPendingMigrationPath(systemDir) + if (!fs.existsSync(filePath)) return null + + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PendingDataDirMigration + if (!parsed.from || !parsed.to || typeof parsed.migrate !== 'boolean') return null + return parsed + } catch { + return null + } +} + +export function clearPendingNodeDataDirMigration(systemDir: string): void { + const filePath = getPendingMigrationPath(systemDir) + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } +} + +// 只删除确认仍是 ChatLab 用户数据目录的旧路径,避免误删用户选择的普通目录。 +export function deleteOldUserDataDirIfSafe( + dirPath: string, + currentDir: string, + removeDir?: (dir: string) => void +): boolean { + if (path.resolve(dirPath) === path.resolve(currentDir)) return false + if (!isPathSafe(dirPath)) return false + if (!fs.existsSync(dirPath)) return false + if (!isExistingUserDataDir(dirPath)) return false + + const remove = removeDir ?? ((dir: string) => fs.rmSync(dir, { recursive: true, force: true })) + remove(dirPath) + return true +} + +function writePendingNodeDataDirMigration(systemDir: string, pending: PendingDataDirMigration): void { + const filePath = getPendingMigrationPath(systemDir) + ensureDir(path.dirname(filePath)) + fs.writeFileSync(filePath, JSON.stringify(pending, null, 2), 'utf-8') +} + +export function createNodeDataDirSwitch(input: { + systemDir: string + currentDir: string + targetDir: string | null + defaultDir?: string + migrate?: boolean + envDataDir?: string +}): DataDirSwitchResult { + if (input.envDataDir) { + return { success: false, error: 'CHATLAB_DATA_DIR is set, data directory cannot be changed from Web UI' } + } + + const newDir = (input.targetDir?.trim() || input.defaultDir || '').trim() + if (!newDir) return { success: false, error: 'Data directory is required' } + if (!path.isAbsolute(newDir)) return { success: false, error: 'Data directory must be an absolute path' } + if (!isPathSafe(newDir)) return { success: false, error: 'System directories cannot be used as data directory' } + + const migrate = input.migrate !== false + if (migrate && path.resolve(input.currentDir) !== path.resolve(newDir) && isSubPath(input.currentDir, newDir)) { + return { success: false, error: 'Target directory cannot be inside current data directory' } + } + + if (!isUserDataDirSafeToUse(newDir)) { + return { success: false, error: 'Target directory is not empty and is not a ChatLab data directory' } + } + + if (path.resolve(input.currentDir) === path.resolve(newDir)) { + clearPendingNodeDataDirMigration(input.systemDir) + return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: false } + } + + const pending = createPendingDataDirMigration({ + from: input.currentDir, + to: newDir, + migrate, + targetWasEmpty: isDirectoryEmptyOrMissing(newDir), + }) + writePendingNodeDataDirMigration(input.systemDir, pending) + + return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: true } +} + +export function applyPendingNodeDataDirMigration( + systemDir: string, + deps: ApplyPendingNodeDataDirMigrationDeps = {} +): { + success: boolean + skipped?: boolean + error?: string +} { + const pending = getPendingNodeDataDirMigration(systemDir) + if (!pending) return { success: true, skipped: true } + const writeConfig = deps.writeConfigField ?? writeConfigField + + const result = runPendingDataDirMigration(pending, { + writeUserDataDir(dir) { + writeConfig('data', 'user_data_dir', dir) + writeConfig('data', 'electron_migration_done', true) + }, + clearPendingMigration() { + clearPendingNodeDataDirMigration(systemDir) + }, + markPendingDeleteDir(dir) { + deleteOldUserDataDirIfSafe(dir, pending.to, deps.removeDir) + }, + }) + + if (!result.success) { + return { success: false, error: result.errors.join('; ') || 'Data directory migration failed' } + } + + return { success: true } +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index c84d252f..2b396221 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -52,9 +52,34 @@ export { hasFtsTable, createFtsTable, buildFtsIndex, rebuildFtsIndex, insertFtsE // AI Logger & Error formatting export { AiLogger, extractErrorInfo, extractErrorStack, formatAIError } from './ai' export type { FormatAIErrorOptions } from './ai' -export { NodePathProvider, hasPendingElectronDataWarning } from './node-path-provider' +export { + NodePathProvider, + applyPendingNodeDataDirMigrationIfNeeded, + getDefaultNodeUserDataDir, + hasPendingElectronDataWarning, +} from './node-path-provider' export { DatabaseManager } from './database-manager' export { createJiebaNlpProvider } from './jieba-nlp-provider' +export { + applyPendingNodeDataDirMigration, + clearPendingNodeDataDirMigration, + copyDirMerge as copyDataDirMerge, + createNodeDataDirSwitch, + createPendingDataDirMigration, + getPendingNodeDataDirMigration, + isDirectoryEmptyOrMissing, + isExistingUserDataDir, + isUserDataDirSafeToUse, + runPendingDataDirMigration, +} from './data-dir-switch' +export type { + ApplyPendingNodeDataDirMigrationDeps, + CopyStats as DataDirCopyStats, + DataDirSwitchResult, + PendingDataDirMigration, + RunPendingDataDirMigrationDeps, + RunPendingDataDirMigrationResult, +} from './data-dir-switch' // NLP 分词引擎、词频统计、词库管理 export { diff --git a/packages/node-runtime/src/node-path-provider.ts b/packages/node-runtime/src/node-path-provider.ts index 926147bb..dcc0040c 100644 --- a/packages/node-runtime/src/node-path-provider.ts +++ b/packages/node-runtime/src/node-path-provider.ts @@ -20,6 +20,7 @@ import * as path from 'path' import type { PathProvider } from '@openchatlab/core' import { writeConfigField, loadConfig } from '@openchatlab/config' import { migrateFromElectronIfNeeded } from './migrations/electron-data-migration' +import { applyPendingNodeDataDirMigration } from './data-dir-switch' const SYSTEM_DIR = path.join(os.homedir(), '.chatlab') @@ -33,6 +34,11 @@ export function hasPendingElectronDataWarning(): boolean { return _pendingElectronDataWarning } +export function applyPendingNodeDataDirMigrationIfNeeded(): { success: boolean; skipped?: boolean; error?: string } { + if (process.env.CHATLAB_DATA_DIR) return { success: true, skipped: true } + return applyPendingNodeDataDirMigration(SYSTEM_DIR) +} + export class NodePathProvider implements PathProvider { private systemDir: string private userDataDir: string @@ -117,7 +123,7 @@ function resolveUserDataDir(): string { return result.userDataDir } - const defaultDir = getDefaultUserDataDir() + const defaultDir = getDefaultNodeUserDataDir() if (result.electronDetected) { // Electron was used but we couldn't find databases — likely a custom data directory. @@ -130,7 +136,7 @@ function resolveUserDataDir(): string { return defaultDir } -function getDefaultUserDataDir(): string { +export function getDefaultNodeUserDataDir(): string { return path.join(os.homedir(), '.chatlab', 'data') } diff --git a/src/components/common/Settings/DataStorage/StorageManageSection.vue b/src/components/common/Settings/DataStorage/StorageManageSection.vue index 4d2f9f50..c71bb2ae 100644 --- a/src/components/common/Settings/DataStorage/StorageManageSection.vue +++ b/src/components/common/Settings/DataStorage/StorageManageSection.vue @@ -17,8 +17,10 @@ const cacheInfo = ref(null) const isLoading = ref(false) const clearingId = ref(null) const dataDir = ref('') +const dataDirInput = ref('') const defaultDataDir = ref('') const isCustomDataDir = ref(false) +const canSetDataDir = ref(false) const isUpdatingDataDir = ref(false) const dataDirError = ref(null) const isMigrateHintIgnored = ref(false) @@ -59,9 +61,13 @@ async function loadCacheInfo() { } // 加载数据目录 +const showDataDirSettings = computed(() => { + return IS_ELECTRON || canSetDataDir.value || Boolean(dataDir.value) +}) + const canMigrateToDefault = computed(() => { return ( - IS_ELECTRON && + showDataDirSettings.value && dataDir.value && defaultDataDir.value && dataDir.value !== defaultDataDir.value && @@ -91,8 +97,10 @@ async function loadDataDir() { try { const info = await useCacheService().getDataDir() dataDir.value = info.path + dataDirInput.value = info.pendingMigration?.to || info.path defaultDataDir.value = info.defaultPath || '' isCustomDataDir.value = info.isCustom + canSetDataDir.value = IS_ELECTRON || Boolean(info.canSetDataDir) updateMigrateHintIgnored() } catch (error) { console.error('获取数据目录失败:', error) @@ -166,6 +174,18 @@ function migrateToDefaultDir() { showConfirmModal.value = true } +function applyTypedDataDir() { + dataDirError.value = null + const targetDir = dataDirInput.value.trim() + if (!targetDir) { + dataDirError.value = t('settings.storage.dataLocation.pathRequired') + return + } + pendingNewDir.value = targetDir + pendingMigrate.value = true + showConfirmModal.value = true +} + function ignoreMigrateHint() { if (!dataDir.value || !defaultDataDir.value) return @@ -194,7 +214,9 @@ function cancelDataDirChange() { async function applyDataDirChange(newDir: string | null, migrate: boolean) { isUpdatingDataDir.value = true try { - const result = await window.cacheApi.setDataDir(newDir, migrate) + const result = IS_ELECTRON + ? await window.cacheApi.setDataDir(newDir, migrate) + : await useCacheService().setDataDir(newDir, migrate) if (!result.success) { dataDirError.value = result.error || '设置失败' return @@ -216,12 +238,16 @@ async function applyDataDirChange(newDir: string | null, migrate: boolean) { // 重启应用 async function relaunchApp() { - await usePlatformService().relaunch() + if (IS_ELECTRON) { + await usePlatformService().relaunch() + return + } + window.location.reload() } onMounted(() => { loadCacheInfo() - if (IS_ELECTRON) loadDataDir() + loadDataDir() }) // 暴露刷新方法 @@ -340,8 +366,8 @@ defineExpose({ - -