mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
1 parent
932b2553b0
commit
6090c6689f
8 files changed
+223
-228
No files matched your search
+28
-227
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user