refactor: extract AiLogger to @openchatlab/node-runtime for shared use

Move the file-based AI logging implementation from Electron-specific
code to a shared AiLogger class in @openchatlab/node-runtime. Both
Electron and Server/CLI now use the same logger writing to
~/.chatlab/logs/ai/. Server-side agent, tool adapter, and compression
now produce the same structured log files as Electron.
This commit is contained in:
digua committed 2026-05-15 22:23:49 +08:00
1 parent 932b2553b0
commit 6090c6689f
8 files changed
+223 -228

No files matched your search

+28 -227
View File
@@ -1,250 +1,51 @@
/**
* AI 日志模块
* 将 AI 相关操作日志写入本地文件
* AI 日志模块(Electron 初始化入口)
*
* 实际实现在 @openchatlab/node-runtime 的 AiLogger 类。
* 此处用 Electron 的 logsDir 初始化单例实例。
*/
import * as fs from 'fs'
import * as path from 'path'
import { AiLogger } from '@openchatlab/node-runtime'
import { getLogsDir } from '../paths'
let debugMode = false
export { extractErrorInfo, extractErrorStack } from '@openchatlab/node-runtime'
let _instance: AiLogger | null = null
function getInstance(): AiLogger {
if (!_instance) _instance = new AiLogger(getLogsDir())
return _instance
}
export const aiLogger = {
debug: (category: string, message: string, data?: unknown) => getInstance().debug(category, message, data),
info: (category: string, message: string, data?: unknown) => getInstance().info(category, message, data),
warn: (category: string, message: string, data?: unknown) => getInstance().warn(category, message, data),
error: (category: string, message: string, data?: unknown) => getInstance().error(category, message, data),
close: () => getInstance().close(),
getLogPath: () => getInstance().getLogPath(),
getExistingLogPath: () => getInstance().getExistingLogPath(),
}
export function setDebugMode(enabled: boolean): void {
debugMode = enabled
getInstance().setDebugMode(enabled)
}
export function isDebugMode(): boolean {
return debugMode
return getInstance().isDebugMode()
}
// 日志目录
let LOG_DIR: string | null = null
let LOG_FILE: string | null = null
let logStream: fs.WriteStream | null = null
function formatPathForLog(filePath: string): string {
return filePath.replace(/ /g, '\\ ')
}
function writeLogFileHeader(stream: fs.WriteStream, filePath: string): void {
const localPath = formatPathForLog(filePath)
stream.write(`Local Path: ${localPath}\n\n`)
}
/**
* 获取日志目录
*/
function getLogDir(): string {
if (LOG_DIR) return LOG_DIR
LOG_DIR = path.join(getLogsDir(), 'ai')
return LOG_DIR
}
/**
* 确保日志目录存在
*/
function ensureLogDir(): void {
const dir = getLogDir()
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
/**
* 获取当前日志文件路径
* 日志文件名格式:ai_YYYY-MM-DD_HH-mm.log
*/
function getLogFilePath(): string {
if (LOG_FILE) return LOG_FILE
ensureLogDir()
const now = new Date()
const date = now.toISOString().split('T')[0]
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
LOG_FILE = path.join(getLogDir(), `ai_${date}_${hours}-${minutes}.log`)
return LOG_FILE
}
/**
* 获取已存在的日志文件路径(不会创建新文件)
*/
function getExistingLogPath(): string | null {
if (LOG_FILE && fs.existsSync(LOG_FILE)) {
return LOG_FILE
}
return null
}
/**
* 获取日志写入流
*/
function getLogStream(): fs.WriteStream {
if (logStream) return logStream
const filePath = getLogFilePath()
const isNewOrEmptyFile = !fs.existsSync(filePath) || fs.statSync(filePath).size === 0
logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf-8' })
if (isNewOrEmptyFile) {
writeLogFileHeader(logStream, filePath)
}
return logStream
}
/**
* 格式化时间戳
*/
function formatTimestamp(): string {
return new Date().toISOString()
}
/**
* 日志级别
*/
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
/**
* 写入日志
* @param level 日志级别
* @param category 分类
* @param message 消息
* @param data 附加数据
* @param toConsole 是否输出到控制台(默认只有 WARN/ERROR 输出)
*/
function writeLog(level: LogLevel, category: string, message: string, data?: any, toConsole: boolean = false): void {
const timestamp = formatTimestamp()
let logLine = `[${timestamp}] [${level}] [${category}] ${message}`
if (data !== undefined) {
try {
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2)
if (!debugMode && dataStr.length > 2000) {
logLine += `\n${dataStr.slice(0, 2000)}...[truncated, ${dataStr.length} chars total]`
} else {
logLine += `\n${dataStr}`
}
} catch {
logLine += `\n[unserializable data]`
}
}
logLine += '\n'
// 写入文件
try {
const stream = getLogStream()
stream.write(logLine)
} catch (error) {
console.error('[AILogger] Failed to write log:', error)
}
// 只在需要时输出到控制台(WARN/ERROR 或明确指定)
if (toConsole || level === 'WARN' || level === 'ERROR') {
console.log(`[AI] ${message}`)
}
}
/**
* AI 日志对象
*/
export const aiLogger = {
debug(category: string, message: string, data?: any) {
writeLog('DEBUG', category, message, data)
},
info(category: string, message: string, data?: any) {
writeLog('INFO', category, message, data)
},
warn(category: string, message: string, data?: any) {
writeLog('WARN', category, message, data)
},
error(category: string, message: string, data?: any) {
writeLog('ERROR', category, message, data)
},
/**
* 关闭日志流
*/
close() {
if (logStream) {
logStream.end()
logStream = null
}
},
/**
* 获取日志文件路径
*/
getLogPath(): string {
return getLogFilePath()
},
/**
* 获取已存在的日志文件路径(无日志时返回空)
*/
getExistingLogPath(): string | null {
return getExistingLogPath()
},
}
/**
* 从 Error 对象中提取错误信息(不含堆栈)
* @param error 任意错误对象
*/
export function extractErrorInfo(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const info: Record<string, unknown> = {
name: error.name,
message: error.message,
}
// 如果有 cause,递归提取
if ('cause' in error && error.cause) {
info.cause = extractErrorInfo(error.cause)
}
return info
}
// 非 Error 对象,尝试转换
if (typeof error === 'object' && error !== null) {
return { raw: JSON.stringify(error) }
}
return { message: String(error) }
}
/**
* 从 Error 对象中提取堆栈信息(用于单独一行日志)
* @param error 任意错误对象
* @param stackLines 保留的堆栈行数(默认 5)
*/
export function extractErrorStack(error: unknown, stackLines: number = 5): string | null {
if (error instanceof Error && error.stack) {
const lines = error.stack.split('\n')
// 跳过第一行(错误消息),保留后续 N 行堆栈
return lines.slice(1, stackLines + 1).join('\n')
}
return null
}
// 导出便捷函数
export function logAI(message: string, data?: any) {
export function logAI(message: string, data?: unknown) {
aiLogger.info('AI', message, data)
}
export function logLLM(message: string, data?: any) {
export function logLLM(message: string, data?: unknown) {
aiLogger.info('LLM', message, data)
}
export function logSearch(message: string, data?: any) {
export function logSearch(message: string, data?: unknown) {
aiLogger.info('Search', message, data)
}
export function logRAG(message: string, data?: any) {
export function logRAG(message: string, data?: unknown) {
aiLogger.info('RAG', message, data)
}
+151
View File
@@ -0,0 +1,151 @@
/**
* AI 日志模块(平台无关)
*
* 将 AI 相关操作日志写入本地文件。Electron 和 Server/CLI 共用。
*/
import * as fs from 'fs'
import * as path from 'path'
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
export class AiLogger {
private debugMode = false
private logDir: string
private logFile: string | null = null
private logStream: fs.WriteStream | null = null
constructor(logsDir: string) {
this.logDir = path.join(logsDir, 'ai')
}
setDebugMode(enabled: boolean): void {
this.debugMode = enabled
}
isDebugMode(): boolean {
return this.debugMode
}
debug(category: string, message: string, data?: unknown): void {
this.writeLog('DEBUG', category, message, data)
}
info(category: string, message: string, data?: unknown): void {
this.writeLog('INFO', category, message, data)
}
warn(category: string, message: string, data?: unknown): void {
this.writeLog('WARN', category, message, data)
}
error(category: string, message: string, data?: unknown): void {
this.writeLog('ERROR', category, message, data)
}
close(): void {
if (this.logStream) {
this.logStream.end()
this.logStream = null
}
}
getLogPath(): string {
return this.getLogFilePath()
}
getExistingLogPath(): string | null {
if (this.logFile && fs.existsSync(this.logFile)) {
return this.logFile
}
return null
}
private ensureLogDir(): void {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true })
}
}
private getLogFilePath(): string {
if (this.logFile) return this.logFile
this.ensureLogDir()
const now = new Date()
const date = now.toISOString().split('T')[0]
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
this.logFile = path.join(this.logDir, `ai_${date}_${hours}-${minutes}.log`)
return this.logFile
}
private getLogStream(): fs.WriteStream {
if (this.logStream) return this.logStream
const filePath = this.getLogFilePath()
const isNewOrEmptyFile = !fs.existsSync(filePath) || fs.statSync(filePath).size === 0
this.logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf-8' })
if (isNewOrEmptyFile) {
this.logStream.write(`Local Path: ${filePath.replace(/ /g, '\\ ')}\n\n`)
}
return this.logStream
}
private writeLog(level: LogLevel, category: string, message: string, data?: unknown): void {
const timestamp = new Date().toISOString()
let logLine = `[${timestamp}] [${level}] [${category}] ${message}`
if (data !== undefined) {
try {
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2)
if (!this.debugMode && dataStr.length > 2000) {
logLine += `\n${dataStr.slice(0, 2000)}...[truncated, ${dataStr.length} chars total]`
} else {
logLine += `\n${dataStr}`
}
} catch {
logLine += `\n[unserializable data]`
}
}
logLine += '\n'
try {
const stream = this.getLogStream()
stream.write(logLine)
} catch (error) {
console.error('[AiLogger] Failed to write log:', error)
}
if (level === 'WARN' || level === 'ERROR') {
console.log(`[AI] ${message}`)
}
}
}
export function extractErrorInfo(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const info: Record<string, unknown> = {
name: error.name,
message: error.message,
}
if ('cause' in error && error.cause) {
info.cause = extractErrorInfo(error.cause)
}
return info
}
if (typeof error === 'object' && error !== null) {
return { raw: JSON.stringify(error) }
}
return { message: String(error) }
}
export function extractErrorStack(error: unknown, stackLines: number = 5): string | null {
if (error instanceof Error && error.stack) {
const lines = error.stack.split('\n')
return lines.slice(1, stackLines + 1).join('\n')
}
return null
}
+3
View File
@@ -4,6 +4,9 @@
* 助手/技能 MD 文件解析器、共享类型、对话管理、Agent Core。
*/
// AI Logger
export { AiLogger, extractErrorInfo, extractErrorStack } from './ai-logger'
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './types'
export { parseAssistantFile, serializeAssistant } from './assistant-parser'
export { parseSkillFile, extractSkillId } from './skill-parser'
+3
View File
@@ -6,6 +6,9 @@
*/
export { BetterSqliteAdapter, openBetterSqliteDatabase } from './better-sqlite3-adapter'
// AI Logger
export { AiLogger, extractErrorInfo, extractErrorStack } from './ai'
export { NodePathProvider } from './node-path-provider'
export { DatabaseManager } from './database-manager'
export { createJiebaNlpProvider } from './jieba-nlp-provider'
+4 -1
View File
@@ -19,6 +19,7 @@ import {
} from '@openchatlab/node-runtime'
import { getDefaultAssistantConfig, buildPiModel } from './llm-config'
import { getServerAiLogger } from './logger'
export interface AgentStreamEvent {
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error'
@@ -190,12 +191,14 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
}
},
}
const aiLogger = getServerAiLogger()
const compressionResult = await checkAndCompress(
conversationId,
compressionConfig,
systemPrompt,
llmAdapter,
convManager
convManager,
aiLogger ?? undefined
)
if (compressionResult.compressed) {
onEvent({ type: 'status', status: { phase: 'compression_done', round: 0, toolsUsed: 0 } })
+28
View File
@@ -0,0 +1,28 @@
/**
* Server 端 AI 日志
*
* 复用 @openchatlab/node-runtime 的 AiLogger,
* 在 startHttpServer() 中通过 initServerAiLogger() 初始化。
*/
import { AiLogger } from '@openchatlab/node-runtime'
let logger: AiLogger | null = null
export function initServerAiLogger(logsDir: string): AiLogger {
if (!logger) {
logger = new AiLogger(logsDir)
}
return logger
}
export function getServerAiLogger(): AiLogger | null {
return logger
}
export function closeServerAiLogger(): void {
if (logger) {
logger.close()
logger = null
}
}
+2
View File
@@ -15,6 +15,7 @@ import {
type PreprocessableMessage,
type TruncationStrategy,
} from '@openchatlab/node-runtime'
import { getServerAiLogger } from './logger'
const DEFAULT_MAX_TOOL_RESULT_TOKENS = 8000
@@ -68,6 +69,7 @@ export function adaptToolsForAgent(
maxToolResultTokens: DEFAULT_MAX_TOOL_RESULT_TOKENS,
truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last',
extraDetails: (result.data ?? {}) as Record<string, unknown>,
logger: getServerAiLogger() ?? undefined,
})
return { content: [{ type: 'text', text: pipelineResult.text }], details: null }
}
+4
View File
@@ -19,6 +19,7 @@ import { registerSessionRoutes } from './routes/sessions'
import { registerWebRoutes } from './routes/web'
import { registerNlpRoutes } from './routes/nlp'
import { registerAiRoutes } from './routes/ai'
import { initServerAiLogger, closeServerAiLogger } from '../ai/logger'
let server: FastifyInstance | null = null
let dbManager: DatabaseManager | null = null
@@ -98,6 +99,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
dbManager = new DatabaseManager(pathProvider, { nativeBinding })
convManager = new AIConversationManager(pathProvider.getAiDataDir(), { nativeBinding })
initServerAiLogger(pathProvider.getLogsDir())
setAuthToken(token)
server = createServer()
@@ -149,6 +152,7 @@ export async function stopHttpServer(): Promise<void> {
dbManager.closeAll()
dbManager = null
}
closeServerAiLogger()
server = null
}
}