From 70ada5031c4b2a7c1603ab6aa5e708ea395f6b01 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 2 Jul 2026 00:23:56 +0800 Subject: [PATCH] fix(ai): share AI log opening route --- apps/cli/src/http/routes/web/index.ts | 2 + apps/desktop/main/internal-api.ts | 2 + apps/desktop/main/ipc/ai.ts | 44 +------------- apps/desktop/preload/apis/ai.ts | 6 -- apps/desktop/preload/index.d.ts | 3 +- packages/http-routes/src/context.ts | 1 + packages/http-routes/src/register.ts | 2 + .../src/routes/shared-routes.test.ts | 57 +++++++++++++++++++ .../http-routes/src/routes/web/ai-logs.ts | 57 +++++++++++++++++++ src/services/ai/electron.ts | 8 +-- src/services/ai/fetch.ts | 7 ++- 11 files changed, 131 insertions(+), 58 deletions(-) create mode 100644 packages/http-routes/src/routes/web/ai-logs.ts diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index f44beebd..9065306d 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -47,6 +47,7 @@ import { registerImportRoutes } from './import' import { openDirectoryPath, showPathInFolder } from './cache' import { getVersion } from '../../../version' import { buildWebUpdateCheckResult } from './update-check' +import { getServerAiLogger } from '../../../ai/logger' export interface AiContextOptions { aiDataDir: string @@ -121,6 +122,7 @@ export function registerWebRoutes( nativeBinding: options?.nativeBinding, semanticIndexService, analyticsService, + getCurrentAiLogPath: () => getServerAiLogger()?.getExistingLogPath() ?? null, openDirectory: openDirectoryPath, showInFolder: showPathInFolder, downloadsDir: resolvedPathProvider.getDownloadsDir(), diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index b39a76ac..fbaeca99 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -43,6 +43,7 @@ import { resolveApiKey, writeAuthProfile, deleteAuthProfile } from '@openchatlab import { getManager as getAIChatManager } from './ai/chats' import { getManager as getAssistantManager } from './ai/assistant/manager' import { getManager as getSkillManager } from './ai/skills/manager' +import { aiLogger } from './ai/logger' import { createElectronRunAgentStream } from './ai/agent-stream-runner' import { createExecuteElectronAiTool } from './ai/tools/debug-executor' import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat' @@ -172,6 +173,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise aiLogger.getExistingLogPath(), semanticIndexService: newSemanticIndexService ?? undefined, openDirectory: (dirPath) => shell.openPath(dirPath).then(() => {}), showInFolder: (filePath) => { diff --git a/apps/desktop/main/ipc/ai.ts b/apps/desktop/main/ipc/ai.ts index ce8fbb2e..112d26a7 100644 --- a/apps/desktop/main/ipc/ai.ts +++ b/apps/desktop/main/ipc/ai.ts @@ -2,13 +2,10 @@ * AI IPC handlers — Electron-only subset * * Most AI functionality has been migrated to shared HTTP/SSE routes. - * This file retains only: debug mode toggle + native shell log file opening. + * This file retains only the debug mode toggle. */ -import { ipcMain, shell } from 'electron' -import * as fs from 'fs' -import * as path from 'path' +import { ipcMain } from 'electron' import { aiLogger, setDebugMode } from '../ai/logger' -import { getLogsDir } from '../paths' import * as assistantManager from '../ai/assistant' import * as skillManager from '../ai/skills' import type { IpcContext } from './types' @@ -37,43 +34,6 @@ export function registerAIHandlers(_ctx: IpcContext): void { aiLogger.info('Config', `Debug mode ${enabled ? 'enabled' : 'disabled'}`) }) - // ==================== AI 日志 ==================== - - ipcMain.handle('ai:showLogFile', async () => { - try { - const existingLogPath = aiLogger.getExistingLogPath() - if (existingLogPath) { - shell.showItemInFolder(existingLogPath) - return { success: true, path: existingLogPath } - } - - const logDir = path.join(getLogsDir(), 'ai') - if (!fs.existsSync(logDir)) { - return { success: false, error: 'No AI log files found' } - } - - const logFiles = fs.readdirSync(logDir).filter((name) => name.startsWith('ai_') && name.endsWith('.log')) - - if (logFiles.length === 0) { - return { success: false, error: 'No AI log files found' } - } - - const latestLog = logFiles - .map((name) => { - const filePath = path.join(logDir, name) - const stat = fs.statSync(filePath) - return { path: filePath, mtimeMs: stat.mtimeMs } - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] - - shell.showItemInFolder(latestLog.path) - return { success: true, path: latestLog.path } - } catch (error) { - console.error('Failed to open AI log file:', error) - return { success: false, error: String(error) } - } - }) - // Desensitize rules, LLM chat, estimateContextTokens, tool testing // have all been migrated to shared HTTP routes. } diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts index 0e807319..9004dc92 100644 --- a/apps/desktop/preload/apis/ai.ts +++ b/apps/desktop/preload/apis/ai.ts @@ -57,11 +57,5 @@ export const aiApi = { } }, - // ===== 日志(native shell) ===== - - showAiLogFile: (): Promise<{ success: boolean; path?: string; error?: string }> => { - return ipcRenderer.invoke('ai:showLogFile') - }, - // Desensitize rules, tool testing, estimateContextTokens have been migrated to shared HTTP routes. } diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index 36c06616..eead8669 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -136,7 +136,7 @@ interface Api { * AiApi — IPC-only subset * * Most AI functionality has been migrated to HTTP shared routes. - * Only export (fs write + progress push) and native shell remain on IPC. + * Only export (fs write + progress push) remains on IPC. */ interface AiApi { exportFilterResultToFile: (params: { @@ -147,7 +147,6 @@ interface AiApi { timeFilter?: TimeFilter }) => Promise<{ success: boolean; filePath?: string; error?: string }> onExportProgress: (callback: (progress: ExportProgress) => void) => () => void - showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }> } // ==================== 新模型系统类型 ==================== diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index e9a51beb..bf0989dd 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -61,6 +61,7 @@ export interface HttpRouteContext { llmConfigStore?: LLMConfigStore customProviderStore?: CustomProviderStore customModelStore?: CustomModelStore + getCurrentAiLogPath?: () => string | null /** 语义索引共享 service — 可选,路由在缺失时优雅跳过 */ semanticIndexService?: SemanticIndexRuntime diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index 41dd63c7..a223aca4 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -29,6 +29,7 @@ import { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' import { registerAiToolRoutes } from './routes/web/ai-tools' import { registerAiChatRoutes } from './routes/web/ai-chats' import { registerAiSummaryRoutes } from './routes/web/ai-summaries' +import { registerAiLogRoutes } from './routes/web/ai-logs' import { registerSemanticIndexRoutes } from './routes/web/ai-semantic-index' import { registerMergeRoutes } from './routes/web/merge' import { registerCacheRoutes } from './routes/web/cache' @@ -91,6 +92,7 @@ export function registerSharedRoutes( registerAiToolRoutes(server, resolvedCtx) registerAiChatRoutes(server, resolvedCtx) registerAiSummaryRoutes(server, resolvedCtx) + registerAiLogRoutes(server, resolvedCtx) registerSemanticIndexRoutes(server, resolvedCtx) // Merge routes (graceful skip when mergeSessionCache is absent) diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index cc4a970e..99b4859a 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -289,6 +289,63 @@ describe('registerSharedRoutes smoke tests', () => { assert.equal(typeof body.duration, 'number') }) + it('POST /_web/ai/logs/show reveals the current AI log file through the host shell', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-ai-log-route-')) + const currentLogPath = path.join(dir, 'logs', 'ai', 'ai_current.log') + fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }) + fs.writeFileSync(currentLogPath, 'current log') + + const ctx = createTestContext() + ctx.pathProvider = { ...ctx.pathProvider, getLogsDir: () => path.join(dir, 'logs') } + ctx.getCurrentAiLogPath = () => currentLogPath + let shownPath: string | null = null + ctx.showInFolder = async (filePath) => { + shownPath = filePath + } + + const routeApp = Fastify() + registerSharedRoutes(routeApp, ctx) + await routeApp.ready() + + try { + const resp = await routeApp.inject({ method: 'POST', url: '/_web/ai/logs/show' }) + + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { success: true, path: currentLogPath }) + assert.equal(shownPath, currentLogPath) + } finally { + await routeApp.close() + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('POST /_web/ai/logs/show does not open files for remote web clients', async () => { + const ctx = createTestContext() + ctx.getCurrentAiLogPath = () => '/tmp/chatlab-test/logs/ai/ai_current.log' + let didOpen = false + ctx.showInFolder = async () => { + didOpen = true + } + + const routeApp = Fastify() + registerSharedRoutes(routeApp, ctx) + await routeApp.ready() + + try { + const resp = await routeApp.inject({ + method: 'POST', + url: '/_web/ai/logs/show', + remoteAddress: '192.168.1.50', + }) + + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { success: false, error: 'Opening AI logs is only supported on this device' }) + assert.equal(didOpen, false) + } finally { + await routeApp.close() + } + }) + it('PATCH /_web/sessions/:id/name updates the shared session metadata', async () => { const db = createSessionDb() const routeApp = Fastify() diff --git a/packages/http-routes/src/routes/web/ai-logs.ts b/packages/http-routes/src/routes/web/ai-logs.ts new file mode 100644 index 00000000..3ca76a31 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-logs.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' + +function findLatestAiLogPath(logsDir: string): string | null { + const aiLogDir = path.join(logsDir, 'ai') + if (!fs.existsSync(aiLogDir)) return null + + const latest = fs + .readdirSync(aiLogDir) + .filter((name) => name.startsWith('ai_') && name.endsWith('.log')) + .map((name) => { + const filePath = path.join(aiLogDir, name) + const stat = fs.statSync(filePath) + return { path: filePath, mtimeMs: stat.mtimeMs } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] + + return latest?.path ?? null +} + +function resolveAiLogPath(ctx: HttpRouteContext): string | null { + const currentPath = ctx.getCurrentAiLogPath?.() + if (currentPath && fs.existsSync(currentPath)) return currentPath + return findLatestAiLogPath(ctx.pathProvider.getLogsDir()) +} + +function isLoopbackAddress(address?: string): boolean { + if (!address) return true + const normalized = address.replace(/^::ffff:/, '') + return ( + normalized === 'localhost' || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' || + normalized.startsWith('127.') + ) +} + +export function registerAiLogRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.post('/_web/ai/logs/show', async (request, reply) => { + if (!ctx.showInFolder) return reply.code(501).send({ success: false, error: 'Not supported' }) + if (!isLoopbackAddress(request.ip)) { + return { success: false, error: 'Opening AI logs is only supported on this device' } + } + + const logPath = resolveAiLogPath(ctx) + if (!logPath) return { success: false, error: 'No AI log files found' } + + try { + await ctx.showInFolder(logPath) + return { success: true, path: logPath } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) +} diff --git a/src/services/ai/electron.ts b/src/services/ai/electron.ts index 8cddfb83..fcbe1d09 100644 --- a/src/services/ai/electron.ts +++ b/src/services/ai/electron.ts @@ -5,8 +5,8 @@ import { FetchAIAdapter } from './fetch' * Electron AI Adapter * * Extends FetchAIAdapter so most queries go through the Internal HTTP Server - * (shared routes). Only features that require IPC (filesystem export with - * progress push, native shell) are overridden here. + * (shared routes). Only filesystem export with progress push still requires + * IPC; native shell operations are exposed by shared HTTP routes. */ export class ElectronAIAdapter extends FetchAIAdapter { override async exportFilterResultToFile( @@ -18,8 +18,4 @@ export class ElectronAIAdapter extends FetchAIAdapter { override onExportProgress(callback: (progress: ExportProgress) => void): () => void { return window.aiApi.onExportProgress(callback) } - - override async showAiLogFile(): Promise<{ success: boolean; path?: string; error?: string }> { - return window.aiApi.showAiLogFile() - } } diff --git a/src/services/ai/fetch.ts b/src/services/ai/fetch.ts index aa9bd31d..f7c09e9f 100644 --- a/src/services/ai/fetch.ts +++ b/src/services/ai/fetch.ts @@ -225,8 +225,11 @@ export class FetchAIAdapter implements AIAdapter { } } - // ===== 日志 ===== async showAiLogFile(): Promise<{ success: boolean; path?: string; error?: string }> { - return { success: false, error: NOT_AVAILABLE_WEB } + try { + return await post<{ success: boolean; path?: string; error?: string }>('/ai/logs/show') + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : NOT_AVAILABLE_WEB } + } } }