📄 workspace.ts • 6399 bytes
/**
* workspace.ts — 工作区快照 + Banner 状态面板
* 从 cli.ts 提取:getDirSize, countHistoryMessages, printBanner,
* buildWorkspaceSnapshot, restoreWorkspaceFromSnapshot
*/
import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { isUsingFallbackKey } from '../crypto-util.js'
import { t } from '../i18n.js'
import type { UserInfo } from '../user.js'
import type { Message } from '../chat.js'
/** 计算目录大小(字节) */
export function getDirSize(dir: string): number {
if (!existsSync(dir)) return 0
let total = 0
const files = readdirSync(dir, { withFileTypes: true })
for (const file of files) {
const path = join(dir, file.name)
if (file.isDirectory()) {
total += getDirSize(path)
} else if (file.isFile()) {
try {
total += statSync(path).size
} catch { /* P5: 文件可能被删除或无权限,静默跳过 */ }
}
}
return total
}
/** 统计对话历史数量 */
export function countHistoryMessages(workspaceDir: string): number {
const historyFile = join(workspaceDir, 'history.json')
if (!existsSync(historyFile)) return 0
try {
const data = JSON.parse(readFileSync(historyFile, 'utf-8'))
return Array.isArray(data) ? data.length : 0
} catch {
return 0
}
}
/** 打印状态面板 */
export function printBanner(
userInfo: UserInfo,
modelInfo: { name: string; model: string; online: boolean; latencyMs?: number } | undefined,
historyCount: number | undefined,
usedBytes: number | undefined,
extras: { BRAND: string; ACCENT: string; MUTED: string; SUCCESS: string; WARN: string; color: any }
): void {
const { BRAND, ACCENT, MUTED, SUCCESS, WARN, color } = extras
const QUOTA_MB = 100
const QUOTA_BYTES = QUOTA_MB * 1024 * 1024
const usedMB = usedBytes ? (usedBytes / 1024 / 1024).toFixed(1) : '0.0'
const usedPercent = usedBytes ? Math.min(100, (usedBytes / QUOTA_BYTES) * 100).toFixed(0) : '0'
console.log('')
console.log(` ${BRAND}────────────────────────────────────────────────────────${color.reset}`)
console.log(` ${ACCENT}${t('status.user')}${color.reset} ${BRAND}${userInfo.username}${color.reset}`)
console.log(` ${ACCENT}${t('status.model')}${color.reset} ${BRAND}${historyCount || 0} ${t('status.history')}${color.reset}`)
console.log(` ${ACCENT}${t('status.storage')}${color.reset} ${BRAND}${usedMB}/${QUOTA_MB}MB (${usedPercent}%)${color.reset}`)
if (modelInfo) {
const statusIcon = modelInfo.online ? SUCCESS : WARN
const statusText = modelInfo.online
? `${BRAND}${t('status.online')} (${modelInfo.latencyMs}${t('status.latency')})${color.reset}`
: `${BRAND}${t('status.offline')}${color.reset}`
console.log(` ${ACCENT}${t('status.model')}${color.reset} ${BRAND}${modelInfo.name}${color.reset} ${statusIcon} ${statusText}`)
}
console.log(` ${BRAND}────────────────────────────────────────────────────────${color.reset}`)
// P1 #28: 加密fallback警告 - 启动时提醒
if (isUsingFallbackKey()) {
console.log('')
console.log(` ${WARN}⚠️ ${t('security.fallback_warning')}${color.reset}`)
console.log(` ${MUTED} ${t("api.masterkey_hint")}${color.reset}`)
}
console.log('')
console.log(` ${ACCENT}${t('input.prompt')} · ${t('input.help')} · ${t('input.exit')}${color.reset}`)
console.log('')
}
/** 构建工作区快照:文件列表 + 会话历史 */
export function buildWorkspaceSnapshot(userInfo: UserInfo, messages: Message[]): string {
const workspaceDir = userInfo.workspaceDir
const files: Record<string, string> = {}
function readDir(dir: string, prefix: string = '') {
try {
const entries = readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
const fullPath = join(dir, entry.name)
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isFile()) {
try {
const stat = statSync(fullPath)
if (stat.size > 1024 * 1024) continue
files[relPath] = readFileSync(fullPath, 'utf-8')
} catch { /* ignore */ }
} else if (entry.isDirectory()) {
readDir(fullPath, relPath)
}
}
} catch { /* ignore */ }
}
readDir(workspaceDir)
const snapshot = {
username: userInfo.username,
timestamp: new Date().toISOString(),
files,
messages,
}
return JSON.stringify(snapshot)
}
/** 恢复工作区快照 */
export function restoreWorkspaceFromSnapshot(
snapshotStr: string,
userInfo: UserInfo,
extras: { WARN: string; color: any }
): Message[] | null {
const { WARN, color } = extras
try {
const snapshot = JSON.parse(snapshotStr)
const workspaceDir = userInfo.workspaceDir
if (snapshot.files && typeof snapshot.files === 'object') {
const fileEntries = Object.entries(snapshot.files)
// P2 #2.4: 限制快照恢复文件数量和总大小
if (fileEntries.length > 5000) {
console.log(` ⚠️ 快照包含 ${fileEntries.length} 个文件,超过上限 5000,跳过恢复`)
return snapshot.messages || null
}
let totalBytes = 0
for (const [relPath, content] of fileEntries) {
const fullPath = join(workspaceDir, relPath)
const dir = join(fullPath, '..')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(fullPath, content as string, 'utf-8')
totalBytes += Buffer.byteLength(content as string, 'utf-8')
if (totalBytes > 100 * 1024 * 1024) { // 100MB 上限
console.log(` ⚠️ 快照文件总大小超过 100MB,停止恢复`)
break
}
}
// 文件静默恢复,存储空间在 printBanner 统一显示
}
if (snapshot.messages && Array.isArray(snapshot.messages)) {
return snapshot.messages
}
return null
} catch (e: any) {
console.log(` ${WARN}${t('model.snapshot_failed')} ${e.message}${color.reset}`)
return null
}
}