mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(data): support CLI web data directory migration
This commit is contained in:
1 parent
a94986f117
commit
e68ce77266
18 files changed
+801
-171
No files matched your search
@@ -0,0 +1,210 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
applyPendingNodeDataDirMigration,
|
||||
createPendingDataDirMigration,
|
||||
createNodeDataDirSwitch,
|
||||
getPendingNodeDataDirMigration,
|
||||
isExistingUserDataDir,
|
||||
runPendingDataDirMigration,
|
||||
} from './data-dir-switch'
|
||||
import { applyPendingNodeDataDirMigrationIfNeeded } from './node-path-provider'
|
||||
|
||||
function makeTempDir(): string {
|
||||
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 {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, content, 'utf-8')
|
||||
}
|
||||
|
||||
test('createPendingDataDirMigration records a restart-time migration without mutating config', () => {
|
||||
const pending = createPendingDataDirMigration({
|
||||
from: '/old/data',
|
||||
to: '/new/data',
|
||||
migrate: true,
|
||||
targetWasEmpty: true,
|
||||
})
|
||||
|
||||
assert.equal(pending.from, '/old/data')
|
||||
assert.equal(pending.to, '/new/data')
|
||||
assert.equal(pending.migrate, true)
|
||||
assert.equal(pending.deleteSourceOnSuccess, true)
|
||||
assert.match(pending.createdAt, /^\d{4}-\d{2}-\d{2}T/)
|
||||
})
|
||||
|
||||
test('runPendingDataDirMigration writes config only after copy succeeds', () => {
|
||||
const root = makeTempDir()
|
||||
const source = path.join(root, 'source')
|
||||
const target = path.join(root, 'target')
|
||||
writeFile(path.join(source, 'databases', 'session.db'), 'sqlite')
|
||||
|
||||
let configuredDir = source
|
||||
let pendingCleared = false
|
||||
let pendingDeleteDir: string | null = null
|
||||
|
||||
const result = runPendingDataDirMigration(
|
||||
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
|
||||
{
|
||||
writeUserDataDir(dir) {
|
||||
configuredDir = dir
|
||||
},
|
||||
clearPendingMigration() {
|
||||
pendingCleared = true
|
||||
},
|
||||
markPendingDeleteDir(dir) {
|
||||
pendingDeleteDir = dir
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(result.success, true)
|
||||
assert.equal(configuredDir, target)
|
||||
assert.equal(pendingCleared, true)
|
||||
assert.equal(pendingDeleteDir, source)
|
||||
assert.equal(fs.readFileSync(path.join(target, 'databases', 'session.db'), 'utf-8'), 'sqlite')
|
||||
})
|
||||
|
||||
test('runPendingDataDirMigration keeps old config and pending task when copy fails', () => {
|
||||
const root = makeTempDir()
|
||||
const source = path.join(root, 'source')
|
||||
const target = path.join(root, 'target')
|
||||
writeFile(path.join(source, 'databases', 'session.db'), 'sqlite')
|
||||
|
||||
let configuredDir = source
|
||||
let pendingCleared = false
|
||||
|
||||
const result = runPendingDataDirMigration(
|
||||
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
|
||||
{
|
||||
copyDirMerge() {
|
||||
return { copied: 0, skipped: 0, errors: ['copy failed'] }
|
||||
},
|
||||
writeUserDataDir(dir) {
|
||||
configuredDir = dir
|
||||
},
|
||||
clearPendingMigration() {
|
||||
pendingCleared = true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(configuredDir, source)
|
||||
assert.equal(pendingCleared, false)
|
||||
assert.equal(fs.existsSync(path.join(target, 'databases', 'session.db')), false)
|
||||
})
|
||||
|
||||
test('runPendingDataDirMigration fails when source directory is missing', () => {
|
||||
const root = makeTempDir()
|
||||
const source = path.join(root, 'missing-source')
|
||||
const target = path.join(root, 'target')
|
||||
|
||||
let configuredDir = source
|
||||
let pendingCleared = false
|
||||
|
||||
const result = runPendingDataDirMigration(
|
||||
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
|
||||
{
|
||||
writeUserDataDir(dir) {
|
||||
configuredDir = dir
|
||||
},
|
||||
clearPendingMigration() {
|
||||
pendingCleared = true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(configuredDir, source)
|
||||
assert.equal(pendingCleared, false)
|
||||
assert.equal(fs.existsSync(target), false)
|
||||
})
|
||||
|
||||
test('isExistingUserDataDir accepts current user data layout without settings directory', () => {
|
||||
const root = makeTempDir()
|
||||
const dataDir = path.join(root, 'data')
|
||||
writeFile(path.join(dataDir, '.chatlab'), 'ChatLab Data Directory')
|
||||
fs.mkdirSync(path.join(dataDir, 'databases'), { recursive: true })
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user