Skip to content

Commit bf4cdc1

Browse files
authored
Merge pull request #5 from BuildWithAIs/feat/logging-system
feat(logging): add persistent logging system with retention and UI
2 parents 9a8770f + 47ba0af commit bf4cdc1

File tree

21 files changed

+603
-26
lines changed

21 files changed

+603
-26
lines changed

electron/main/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Electron 主进程目录,负责窗口管理、IPC、录音流程、ASR 调用
77
- `main.ts` - 应用入口;创建后台/设置/浮窗窗口、托盘菜单与 IPC 处理,协调 PTT 录音 → 转录 → 注入流程、会话取消与 FFmpeg 初始化。
88
- `i18n.ts` - 主进程 i18next 初始化与语言切换。
99
- `config-manager.ts` - 使用 `electron-store` 持久化应用偏好、ASR 配置与快捷键配置。
10+
- `logger.ts` - 初始化 `electron-log`,统一控制台写入与日志保留/轮转策略。
1011
- `history-manager.ts` - 录音历史存储(固定保留最近 90 天),提供增删清空与统计接口。
1112
- `hotkey-manager.ts` - 基于 `globalShortcut` 的全局快捷键注册/注销。
1213
- `iohook-manager.ts` - 基于 `uiohook-napi` 的键盘钩子,检测 PTT 组合键按住状态。

electron/main/asr-provider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import axios from 'axios'
22
import FormData from 'form-data'
33
import fs from 'fs'
4+
import { createHash } from 'node:crypto'
45
import { GLM_ASR } from '../shared/constants'
56
import { ASRConfig } from '../shared/types'
67

@@ -88,9 +89,9 @@ export class ASRProvider {
8889
}
8990

9091
const receivedText = response.data.text
91-
console.log('[ASR] Raw response text:', receivedText)
92+
const textHash = createHash('sha256').update(receivedText, 'utf8').digest('hex')
9293
console.log('[ASR] Text length:', receivedText.length)
93-
console.log('[ASR] Text bytes:', Buffer.from(receivedText, 'utf8').toString('hex'))
94+
console.log('[ASR] Text hash (sha256):', textHash)
9495

9596
const totalDuration = Date.now() - transcribeStartTime
9697
console.log(`[ASR] ⏱️ Total transcribe() call took ${totalDuration}ms`)

electron/main/logger.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { app } from 'electron'
2+
import log from 'electron-log'
3+
import fs from 'fs'
4+
import path from 'path'
5+
import {
6+
LOG_DATA_MAX_LENGTH,
7+
LOG_FILE_MAX_SIZE_BYTES,
8+
LOG_MESSAGE_MAX_LENGTH,
9+
LOG_RETENTION_DAYS,
10+
LOG_STACK_HEAD_LINES,
11+
LOG_STACK_TAIL_LINES,
12+
} from '../shared/constants'
13+
import type { LogEntryPayload } from '../shared/types'
14+
15+
const LOG_FILE_NAME = 'voice-key.log'
16+
const LOG_FILE_PREFIX = 'voice-key'
17+
const MAX_DATA_LENGTH = LOG_DATA_MAX_LENGTH
18+
const MAX_MESSAGE_LENGTH = LOG_MESSAGE_MAX_LENGTH
19+
20+
let initialized = false
21+
22+
const getLogDir = () => app.getPath('logs')
23+
const getLogFilePath = () => path.join(getLogDir(), LOG_FILE_NAME)
24+
25+
const clampText = (value: string, maxLength: number) => {
26+
if (value.length <= maxLength) return value
27+
const lines = value.split('\n')
28+
if (lines.length > LOG_STACK_HEAD_LINES + LOG_STACK_TAIL_LINES) {
29+
const head = lines.slice(0, LOG_STACK_HEAD_LINES).join('\n')
30+
const tail = lines.slice(-LOG_STACK_TAIL_LINES).join('\n')
31+
const omitted = lines.length - (LOG_STACK_HEAD_LINES + LOG_STACK_TAIL_LINES)
32+
return `${head}\n... (${omitted} lines omitted) ...\n${tail}`
33+
}
34+
return `${value.slice(0, maxLength)}...`
35+
}
36+
37+
const sanitize = (value: string) => value.replace(/\r/g, '\\r').replace(/\n/g, '\\n')
38+
39+
const safeStringify = (data: unknown): string => {
40+
if (data === undefined) return ''
41+
if (typeof data === 'string') return sanitize(data)
42+
if (data instanceof Error) {
43+
const stack = data.stack ? `\n${data.stack}` : ''
44+
return sanitize(`${data.name}: ${data.message}${stack}`)
45+
}
46+
try {
47+
return sanitize(JSON.stringify(data))
48+
} catch {
49+
return '[unserializable]'
50+
}
51+
}
52+
53+
const formatArgs = (args: unknown[]) => {
54+
const text = args.map((arg) => safeStringify(arg)).join(' ')
55+
return clampText(text, MAX_MESSAGE_LENGTH)
56+
}
57+
58+
const ensureLogDir = () => {
59+
const dir = getLogDir()
60+
if (!fs.existsSync(dir)) {
61+
fs.mkdirSync(dir, { recursive: true })
62+
}
63+
}
64+
65+
const cleanupOldLogs = async () => {
66+
const dir = getLogDir()
67+
if (!fs.existsSync(dir)) return
68+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
69+
const currentLogFile = getLogFilePath()
70+
71+
for (const entry of fs.readdirSync(dir)) {
72+
if (!entry.startsWith(LOG_FILE_PREFIX)) continue
73+
const filePath = path.join(dir, entry)
74+
if (filePath === currentLogFile) continue
75+
try {
76+
const stats = await fs.promises.stat(filePath)
77+
if (!stats.isFile()) continue
78+
if (stats.mtimeMs < cutoff) {
79+
await fs.promises.unlink(filePath).catch((error: NodeJS.ErrnoException) => {
80+
if (error.code !== 'EBUSY') throw error
81+
})
82+
}
83+
} catch (error) {
84+
log.scope('main').warn('[Logger] Failed to cleanup log file', {
85+
filePath,
86+
error: safeStringify(error),
87+
})
88+
}
89+
}
90+
}
91+
92+
const configureTransports = () => {
93+
log.transports.file.resolvePath = getLogFilePath
94+
log.transports.file.maxSize = LOG_FILE_MAX_SIZE_BYTES
95+
log.transports.file.level = process.env.VITE_DEV_SERVER_URL ? 'debug' : 'info'
96+
log.transports.file.format = '{y}-{m}-{d} {h}:{i}:{s}.{ms} [{level}] [{scope}] {text}'
97+
log.transports.console.level = false
98+
99+
log.transports.file.archiveLog = (oldLogFile) => {
100+
const filePath = oldLogFile.path
101+
const dir = path.dirname(filePath)
102+
const ext = path.extname(filePath)
103+
const base = path.basename(filePath, ext)
104+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
105+
const archivedPath = path.join(dir, `${base}-${timestamp}${ext}`)
106+
fs.renameSync(filePath, archivedPath)
107+
}
108+
}
109+
110+
const attachConsole = (scopedLog: ReturnType<typeof log.scope>) => {
111+
const original = {
112+
log: console.log.bind(console),
113+
info: console.info.bind(console),
114+
warn: console.warn.bind(console),
115+
error: console.error.bind(console),
116+
debug: console.debug.bind(console),
117+
}
118+
119+
console.log = (...args: unknown[]) => {
120+
scopedLog.info(formatArgs(args))
121+
original.log(...args)
122+
}
123+
console.info = (...args: unknown[]) => {
124+
scopedLog.info(formatArgs(args))
125+
original.info(...args)
126+
}
127+
console.warn = (...args: unknown[]) => {
128+
scopedLog.warn(formatArgs(args))
129+
original.warn(...args)
130+
}
131+
console.error = (...args: unknown[]) => {
132+
scopedLog.error(formatArgs(args))
133+
original.error(...args)
134+
}
135+
console.debug = (...args: unknown[]) => {
136+
scopedLog.debug(formatArgs(args))
137+
original.debug(...args)
138+
}
139+
}
140+
141+
export const initializeLogger = () => {
142+
if (initialized) return log
143+
ensureLogDir()
144+
configureTransports()
145+
let errorCount = 0
146+
log.catchErrors({
147+
showDialog: false,
148+
onError: () => {
149+
errorCount += 1
150+
return errorCount <= 10
151+
},
152+
})
153+
void cleanupOldLogs()
154+
155+
const scoped = log.scope('main')
156+
attachConsole(scoped)
157+
scoped.info('[Logger] Initialized', {
158+
logFile: getLogFilePath(),
159+
retentionDays: LOG_RETENTION_DAYS,
160+
maxFileSizeBytes: LOG_FILE_MAX_SIZE_BYTES,
161+
})
162+
163+
initialized = true
164+
return log
165+
}
166+
167+
export const writeLog = ({ level, message, scope, data }: LogEntryPayload) => {
168+
const target = log.scope(scope ?? 'main')
169+
const extra = data === undefined ? '' : clampText(safeStringify(data), MAX_DATA_LENGTH)
170+
const text = extra ? `${message} ${extra}` : message
171+
172+
switch (level) {
173+
case 'debug':
174+
target.debug(text)
175+
break
176+
case 'warn':
177+
target.warn(text)
178+
break
179+
case 'error':
180+
target.error(text)
181+
break
182+
default:
183+
target.info(text)
184+
break
185+
}
186+
}
187+
188+
export const readLogTail = (maxBytes: number) => {
189+
const filePath = getLogFilePath()
190+
try {
191+
if (!fs.existsSync(filePath)) return ''
192+
const stats = fs.statSync(filePath)
193+
const size = stats.size
194+
if (size === 0) return ''
195+
const readSize = Math.min(size, maxBytes)
196+
const buffer = Buffer.alloc(readSize)
197+
const fd = fs.openSync(filePath, 'r')
198+
try {
199+
fs.readSync(fd, buffer, 0, readSize, size - readSize)
200+
} finally {
201+
fs.closeSync(fd)
202+
}
203+
const text = buffer.toString('utf8')
204+
return text.includes('\uFFFD') ? text.replace(/\uFFFD/g, '?') : text
205+
} catch (error) {
206+
log.scope('main').error('[Logger] Failed to read log tail', safeStringify(error))
207+
return ''
208+
}
209+
}
210+
211+
export const getLogDirectory = () => getLogDir()

electron/main/main.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Tray,
77
Menu,
88
nativeImage,
9+
shell,
910
screen,
1011
} from 'electron'
1112
import fs from 'fs'
@@ -19,9 +20,17 @@ import { historyManager } from './history-manager'
1920
import { hotkeyManager } from './hotkey-manager'
2021
import { initMainI18n, setMainLanguage, t } from './i18n'
2122
import { ioHookManager } from './iohook-manager'
23+
import { getLogDirectory, initializeLogger, readLogTail, writeLog } from './logger'
2224
import { textInjector } from './text-injector'
2325
import { UpdaterManager } from './updater-manager'
24-
import { IPC_CHANNELS, OverlayState, VoiceSession } from '../shared/types'
26+
import { LOG_TAIL_MAX_BYTES } from '../shared/constants'
27+
import {
28+
IPC_CHANNELS,
29+
type LogEntryPayload,
30+
type LogTailOptions,
31+
type OverlayState,
32+
type VoiceSession,
33+
} from '../shared/types'
2534
// ES Module compatibility - 延迟导入 fluent-ffmpeg 避免启动时的 __dirname 错误
2635
let ffmpeg: any
2736
let ffmpegInitialized = false
@@ -658,11 +667,7 @@ async function handleAudioData(buffer: Buffer) {
658667
const asrDuration = Date.now() - asrStartTime
659668
console.log(`[Main] [${new Date().toISOString()}] Transcription received`)
660669
console.log(`[Main] ⏱️ ASR transcription took ${asrDuration}ms`)
661-
console.log(
662-
'[Main] Transcription received (bytes):',
663-
Buffer.from(transcription.text).toString('hex'),
664-
)
665-
console.log('[Main] Transcription text:', transcription.text)
670+
console.log('[Main] Transcription received (length):', transcription.text.length)
666671

667672
currentSession.transcription = transcription.text
668673
currentSession.status = 'completed'
@@ -827,6 +832,27 @@ function setupIPCHandlers() {
827832
ipcMain.handle(IPC_CHANNELS.HISTORY_CLEAR, () => historyManager.clear())
828833
ipcMain.handle(IPC_CHANNELS.HISTORY_DELETE, (_event, id) => historyManager.delete(id))
829834

835+
// 日志相关
836+
ipcMain.handle(IPC_CHANNELS.LOG_GET_TAIL, (_event, options?: LogTailOptions) => {
837+
const maxBytes = Math.max(
838+
1024,
839+
Math.min(options?.maxBytes ?? LOG_TAIL_MAX_BYTES, LOG_TAIL_MAX_BYTES * 5),
840+
)
841+
return readLogTail(maxBytes)
842+
})
843+
844+
ipcMain.handle(IPC_CHANNELS.LOG_OPEN_FOLDER, () => {
845+
return shell.openPath(getLogDirectory())
846+
})
847+
848+
ipcMain.on(IPC_CHANNELS.LOG_WRITE, (_event, payload: LogEntryPayload) => {
849+
if (!payload || !payload.message || !payload.level) return
850+
writeLog({
851+
...payload,
852+
scope: payload.scope ?? 'renderer',
853+
})
854+
})
855+
830856
// 接收音频数据
831857
ipcMain.on(IPC_CHANNELS.AUDIO_DATA, (_event, buffer) => {
832858
handleAudioData(Buffer.from(buffer))
@@ -874,6 +900,7 @@ function setupIPCHandlers() {
874900

875901
// 应用程序生命周期
876902
app.whenReady().then(async () => {
903+
initializeLogger()
877904
if (process.platform !== 'darwin') {
878905
Menu.setApplicationMenu(null)
879906
}

electron/main/text-injector.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { clipboard, type NativeImage } from 'electron'
22
import { keyboard, Key } from '@nut-tree-fork/nut-js'
3+
import { createHash } from 'node:crypto'
34

45
type ClipboardSnapshot = {
56
text?: string
@@ -21,9 +22,9 @@ export class TextInjector {
2122
}
2223

2324
try {
24-
console.log('[TextInjector] Text to inject:', text)
25-
console.log('[TextInjector] Text bytes:', Buffer.from(text).toString('hex'))
25+
const textHash = createHash('sha256').update(text, 'utf8').digest('hex')
2626
console.log('[TextInjector] Text length:', text.length)
27+
console.log('[TextInjector] Text hash (sha256):', textHash)
2728

2829
const delayStartTime = Date.now()
2930
await this.delay(100)

electron/preload/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ IPC 通信桥接脚本,运行在渲染进程上下文但可访问部分 Node.j
4848
- `getUpdateStatus()` - 获取启动时自动检查的缓存结果(如果有)
4949
- `openExternal(url)` - 打开外部链接(用于发布页)
5050

51+
**日志**
52+
53+
- `getLogTail(options)` - 获取日志尾部文本(可限制读取字节数)
54+
- `openLogFolder()` - 打开日志目录
55+
- `log(entry)` - 渲染进程发送日志到主进程(带 level/message)
56+
5157
#### 安全机制
5258

5359
- 使用 `contextBridge` 避免直接暴露 Node.js 能力

electron/preload/preload.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
22
import {
33
IPC_CHANNELS,
4-
OverlayState,
5-
HistoryItem,
6-
AppConfig,
7-
ASRConfig,
8-
UpdateInfo,
4+
type OverlayState,
5+
type HistoryItem,
6+
type AppConfig,
7+
type ASRConfig,
8+
type UpdateInfo,
9+
type LogEntryPayload,
10+
type LogTailOptions,
911
} from '../shared/types'
1012

1113
// 定义暴露给渲染进程的API接口
@@ -55,8 +57,13 @@ export interface ElectronAPI {
5557
getAppVersion: () => Promise<string>
5658
openExternal: (url: string) => Promise<void>
5759

58-
// 取消会话
60+
// 取消会话 (来自 main 分支的新功能)
5961
cancelSession: () => Promise<void>
62+
63+
// 日志相关 (来自我们分支的新功能)
64+
getLogTail: (options?: LogTailOptions) => Promise<string>
65+
openLogFolder: () => Promise<void>
66+
log: (entry: LogEntryPayload) => void
6067
}
6168

6269
// 暴露安全的API到渲染进程
@@ -151,6 +158,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
151158
getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION),
152159
openExternal: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
153160

154-
// 取消会话
161+
// 取消会话 (来自 main 分支的新功能)
155162
cancelSession: () => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_SESSION),
163+
164+
// 日志相关 (来自我们分支的新功能)
165+
getLogTail: (options?: LogTailOptions) => ipcRenderer.invoke(IPC_CHANNELS.LOG_GET_TAIL, options),
166+
openLogFolder: () => ipcRenderer.invoke(IPC_CHANNELS.LOG_OPEN_FOLDER),
167+
log: (entry: LogEntryPayload) => ipcRenderer.send(IPC_CHANNELS.LOG_WRITE, entry),
156168
} as ElectronAPI)

0 commit comments

Comments
 (0)