♻️ refactor: clean desktop relative code

This commit is contained in:
Innei
2025-12-20 21:41:22 +08:00
committed by arvinxx
parent 492d3ccbf6
commit ffd7d23d5c
16 changed files with 122 additions and 300 deletions
@@ -1,37 +0,0 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import type { NextRequest } from 'next/server';
import { pino } from '@/libs/logger';
import { createLambdaContext } from '@/libs/trpc/lambda/context';
import { prepareRequestForTRPC } from '@/libs/trpc/utils/request-adapter';
import { desktopRouter } from '@/server/routers/desktop';
const handler = (req: NextRequest) => {
// Clone the request to avoid "Response body object should not be disturbed or locked" error
// in Next.js 16 when the body stream has been consumed by Next.js internal mechanisms
const preparedReq = prepareRequestForTRPC(req);
return fetchRequestHandler({
/**
* @link https://trpc.io/docs/v11/context
*/
createContext: () => createLambdaContext(req),
endpoint: '/trpc/desktop',
onError: ({ error, path, type }) => {
pino.info(`Error in tRPC handler (desktop) on path: ${path}, type: ${type}`);
console.error(error);
},
req: preparedReq,
responseMeta({ ctx }) {
const headers = ctx?.resHeaders;
return { headers };
},
router: desktopRouter,
});
};
export { handler as GET, handler as POST };
+7 -3
View File
@@ -3,8 +3,12 @@ import { join } from 'path';
import { describe, expect, it } from 'vitest';
describe('Desktop TRPC Route', () => {
it('should have desktop directory', () => {
const desktopPath = join(__dirname, 'desktop');
expect(existsSync(desktopPath)).toBe(true);
it('should have expected trpc route directories', () => {
const routeDirs = ['async', 'lambda', 'mobile', 'tools'];
for (const dir of routeDirs) {
const routePath = join(__dirname, dir);
expect(existsSync(routePath)).toBe(true);
}
});
});
+13 -6
View File
@@ -5,14 +5,21 @@ import { describe, expect, it } from 'vitest';
describe('Desktop Routes', () => {
const appRootDir = resolve(__dirname, '..');
const desktopRoutes = [
'(backend)/trpc/desktop/[trpc]/route.ts',
'desktop/devtools/page.tsx',
'desktop/layout.tsx',
/**
* Desktop-specific "routes" used by the desktop app are implemented via:
* - backend desktop OIDC callback route
* - desktop router configs/components under `[variants]/router`
*
* This test is intentionally a "smoke check" to prevent accidental deletion.
*/
const desktopRelatedEntries = [
'(backend)/oidc/callback/desktop/route.ts',
'[variants]/router/DesktopClientRouter.tsx',
'[variants]/router/desktopRouter.config.tsx',
];
it.each(desktopRoutes)('should have file: %s', (route) => {
const filePath = resolve(appRootDir, route);
it.each(desktopRelatedEntries)('should have file: %s', (entry) => {
const filePath = resolve(appRootDir, entry);
expect(fs.existsSync(filePath)).toBe(true);
});
});
+49 -31
View File
@@ -6,12 +6,14 @@ describe('MCPClient', () => {
// --- Updated Stdio Transport tests ---
describe('Stdio Transport', () => {
let mcpClient: MCPClient;
const TIMEOUT = 120_000;
const stdioConnection = {
id: 'mcp-hello-world',
name: 'Stdio SDK Test Connection',
type: 'stdio' as const,
command: 'npx', // Use node to run the compiled mock server
args: ['mcp-hello-world@1.1.2'], // Use the path to the compiled JS file
// Ensure non-interactive execution to avoid hanging on install confirmation in CI.
args: ['--yes', 'mcp-hello-world@1.1.2'],
};
beforeEach(async () => {
@@ -21,48 +23,64 @@ describe('MCPClient', () => {
await mcpClient.initialize();
// Add a small delay to allow the server process to fully start (optional, but can help)
await new Promise((resolve) => setTimeout(resolve, 100));
}, 30000);
}, TIMEOUT);
afterEach(async () => {
// Assume SDK client/transport handles process termination gracefully
// If processes leak, more explicit cleanup might be needed here
}, 30000);
}, TIMEOUT);
it('should create and initialize an instance with stdio transport', () => {
expect(mcpClient).toBeInstanceOf(MCPClient);
}, 30000);
it(
'should create and initialize an instance with stdio transport',
() => {
expect(mcpClient).toBeInstanceOf(MCPClient);
},
TIMEOUT,
);
it('should list tools via stdio', async () => {
const result = await mcpClient.listTools();
it(
'should list tools via stdio',
async () => {
const result = await mcpClient.listTools();
// Check exact length if no other tools are expected
expect(result).toHaveLength(3);
// Check exact length if no other tools are expected
expect(result).toHaveLength(3);
// Expect the tools defined in mock-sdk-server.ts
expect(result).toMatchSnapshot();
}, 30000);
// Expect the tools defined in mock-sdk-server.ts
expect(result).toMatchSnapshot();
},
TIMEOUT,
);
it('should call the "echo" tool via stdio', async () => {
const toolName = 'echo';
const toolArgs = { message: 'hello stdio' };
// Expect the result format defined in mock-sdk-server.ts
const expectedResult = {
content: [{ type: 'text', text: 'You said: hello stdio' }],
};
it(
'should call the "echo" tool via stdio',
async () => {
const toolName = 'echo';
const toolArgs = { message: 'hello stdio' };
// Expect the result format defined in mock-sdk-server.ts
const expectedResult = {
content: [{ type: 'text', text: 'You said: hello stdio' }],
};
const result = await mcpClient.callTool(toolName, toolArgs);
expect(result).toEqual(expectedResult);
}, 30000);
const result = await mcpClient.callTool(toolName, toolArgs);
expect(result).toEqual(expectedResult);
},
TIMEOUT,
);
it('should call the "add" tool via stdio', async () => {
const toolName = 'add';
const toolArgs = { a: 5, b: 7 };
it(
'should call the "add" tool via stdio',
async () => {
const toolName = 'add';
const toolArgs = { a: 5, b: 7 };
const result = await mcpClient.callTool(toolName, toolArgs);
expect(result).toEqual({
content: [{ type: 'text', text: 'The sum is: 12' }],
});
}, 30000);
const result = await mcpClient.callTool(toolName, toolArgs);
expect(result).toEqual({
content: [{ type: 'text', text: 'The sum is: 12' }],
});
},
TIMEOUT,
);
});
// Error Handling tests remain the same...
+2 -7
View File
@@ -1,20 +1,15 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { isDesktop } from '@/const/version';
import { withElectronProtocolIfElectron } from '@/const/protocol';
import { AsyncRouter } from '@/server/routers/async';
import { fetchWithDesktopRemoteRPC } from '@/utils/electron/desktopRemoteRPCFetch';
export const asyncClient = createTRPCClient<AsyncRouter>({
links: [
httpBatchLink({
fetch: isDesktop
? // eslint-disable-next-line no-undef
(input, init) => fetchWithDesktopRemoteRPC(input as string, init as RequestInit)
: undefined,
maxURLLength: 2083,
transformer: superjson,
url: '/trpc/async',
url: withElectronProtocolIfElectron('/trpc/async'),
}),
],
});
-13
View File
@@ -1,13 +0,0 @@
import { createTRPCClient, httpLink } from '@trpc/client';
import superjson from 'superjson';
import type { DesktopRouter } from '@/server/routers/desktop';
export const desktopClient = createTRPCClient<DesktopRouter>({
links: [
httpLink({
transformer: superjson,
url: '/trpc/desktop',
}),
],
});
-1
View File
@@ -1,4 +1,3 @@
export { asyncClient } from './async';
export * from './desktop';
export * from './lambda';
export * from './tools';
+31 -17
View File
@@ -1,10 +1,11 @@
import { TRPCLink, createTRPCClient, httpBatchLink } from '@trpc/client';
import { TRPCLink, createTRPCClient, httpBatchLink, httpLink, splitLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { observable } from '@trpc/server/observable';
import debug from 'debug';
import { ModelProvider } from 'model-bank';
import superjson from 'superjson';
import { withElectronProtocolIfElectron } from '@/const/protocol';
import { isDesktop } from '@/const/version';
import type { LambdaRouter } from '@/server/routers/lambda';
@@ -33,15 +34,19 @@ const errorHandlingLink: TRPCLink<LambdaRouter> = () => {
// Don't show notifications for abort errors
if (showError && !isAbortError) {
const { loginRequired } = await import('@/components/Error/loginRequiredNotification');
switch (status) {
case 401: {
// Debounce: only show login notification once every 5 seconds
const now = Date.now();
if (now - last401Time > MIN_401_INTERVAL) {
last401Time = now;
loginRequired.redirect();
// Desktop app doesn't have the web auth routes like `/signin`,
// so skip the login redirect/notification there.
if (!isDesktop) {
const { loginRequired } =
await import('@/components/Error/loginRequiredNotification');
loginRequired.redirect();
}
}
// Mark error as non-retryable to prevent SWR infinite retry loop
err.meta = { ...err.meta, shouldRetry: false };
@@ -61,14 +66,13 @@ const errorHandlingLink: TRPCLink<LambdaRouter> = () => {
);
};
// 2. httpBatchLink
const customHttpBatchLink = httpBatchLink({
fetch: async (input, init) => {
// 2. Shared link options
const linkOptions = {
// eslint-disable-next-line no-undef
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
if (isDesktop) {
const { desktopRemoteRPCFetch } = await import('@/utils/electron/desktopRemoteRPCFetch');
// eslint-disable-next-line no-undef
const res = await desktopRemoteRPCFetch(input as string, init as RequestInit);
const res = await fetch(input as string, init as RequestInit);
if (res) return res;
}
@@ -85,9 +89,8 @@ const customHttpBatchLink = httpBatchLink({
log('Getting provider from store for image page: %s', location.pathname);
if (location.pathname === '/image') {
const { getImageStoreState } = await import('@/store/image');
const { imageGenerationConfigSelectors } = await import(
'@/store/image/slices/generationConfig/selectors'
);
const { imageGenerationConfigSelectors } =
await import('@/store/image/slices/generationConfig/selectors');
provider = imageGenerationConfigSelectors.provider(getImageStoreState()) as ModelProvider;
log('Getting provider from store for image page: %s', provider);
}
@@ -98,13 +101,24 @@ const customHttpBatchLink = httpBatchLink({
log('Headers: %O', headers);
return headers;
},
maxURLLength: 2083,
transformer: superjson,
url: '/trpc/lambda',
url: withElectronProtocolIfElectron('/trpc/lambda'),
};
// Procedures that should skip batching for faster initial load
const initialLoadProcedures = new Set(['user.getUserState', 'config.getGlobalConfig']);
const slowProcedures = new Set(['market.getAssistantList']);
const SKIP_BATCH_PROCEDURES = new Set([...initialLoadProcedures, ...slowProcedures]);
// 3. splitLink to conditionally disable batching
const customSplitLink = splitLink({
condition: (op) => SKIP_BATCH_PROCEDURES.has(op.path),
false: httpBatchLink({ ...linkOptions, maxURLLength: 2083 }),
true: httpLink(linkOptions),
});
// 3. assembly links
const links = [errorHandlingLink, customHttpBatchLink];
// 4. assembly links
const links = [errorHandlingLink, customSplitLink];
export const lambdaClient = createTRPCClient<LambdaRouter>({
links,
+2 -7
View File
@@ -1,17 +1,12 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { isDesktop } from '@/const/version';
import { withElectronProtocolIfElectron } from '@/const/protocol';
import type { ToolsRouter } from '@/server/routers/tools';
import { fetchWithDesktopRemoteRPC } from '@/utils/electron/desktopRemoteRPCFetch';
export const toolsClient = createTRPCClient<ToolsRouter>({
links: [
httpBatchLink({
fetch: isDesktop
? // eslint-disable-next-line no-undef
(input, init) => fetchWithDesktopRemoteRPC(input as string, init as RequestInit)
: undefined,
headers: async () => {
// dynamic import to avoid circular dependency
const { createHeaderWithAuth } = await import('@/services/_auth');
@@ -20,7 +15,7 @@ export const toolsClient = createTRPCClient<ToolsRouter>({
},
maxURLLength: 2083,
transformer: superjson,
url: '/trpc/tools',
url: withElectronProtocolIfElectron('/trpc/tools'),
}),
],
});
-11
View File
@@ -1,11 +0,0 @@
import { router } from '@/libs/trpc/lambda';
import { mcpRouter } from '@/server/routers/desktop/mcp';
import { pgTableRouter } from './pgTable';
export const desktopRouter = router({
mcp: mcpRouter,
pgTable: pgTableRouter,
});
export type DesktopRouter = typeof desktopRouter;
-121
View File
@@ -1,121 +0,0 @@
import { GetStreamableMcpServerManifestInputSchema } from '@lobechat/types';
import debug from 'debug';
import { z } from 'zod';
import { ToolCallContent } from '@/libs/mcp';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { FileService } from '@/server/services/file';
import { mcpService } from '@/server/services/mcp';
import { processContentBlocks } from '@/server/services/mcp/contentProcessor';
const log = debug('lobe-mcp:router');
const stdioParamsSchema = z.object({
args: z.array(z.string()).optional().default([]),
command: z.string().min(1),
env: z.any().optional(),
metadata: z
.object({
avatar: z.string().optional(),
description: z.string().optional(),
name: z.string().optional(),
})
.optional(),
name: z.string().min(1),
type: z.literal('stdio').default('stdio'),
});
const mcpProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
return next({
ctx: {
fileService: new FileService(ctx.serverDB, ctx.userId),
},
});
});
export const mcpRouter = router({
getStdioMcpServerManifest: mcpProcedure.input(stdioParamsSchema).query(async ({ input }) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { env: _, ...rest } = input;
log('getStdioMcpServerManifest input: %O', rest);
return await mcpService.getStdioMcpServerManifest(input, input.metadata);
}),
getStreamableMcpServerManifest: mcpProcedure
.input(GetStreamableMcpServerManifestInputSchema)
.query(async ({ input }) => {
log('getStreamableMcpServerManifest input: %O', {
identifier: input.identifier,
url: input.url,
});
return await mcpService.getStreamableMcpServerManifest(
input.identifier,
input.url,
input.metadata,
input.auth,
input.headers,
);
}),
/* eslint-disable sort-keys-fix/sort-keys-fix */
// --- MCP Interaction ---
// listTools now accepts MCPClientParams directly
listTools: mcpProcedure
.input(stdioParamsSchema) // Use the unified schema
.query(async ({ input }) => {
// Pass the validated MCPClientParams to the service
return await mcpService.listTools(input);
}),
// listResources now accepts MCPClientParams directly
listResources: mcpProcedure
.input(stdioParamsSchema) // Use the unified schema
.query(async ({ input }) => {
// Pass the validated MCPClientParams to the service
return await mcpService.listResources(input);
}),
// listPrompts now accepts MCPClientParams directly
listPrompts: mcpProcedure
.input(stdioParamsSchema) // Use the unified schema
.query(async ({ input }) => {
// Pass the validated MCPClientParams to the service
return await mcpService.listPrompts(input);
}),
// callTool now accepts MCPClientParams, toolName, and args
callTool: mcpProcedure
.input(
z.object({
params: stdioParamsSchema, // Use the unified schema for client params
args: z.any(), // Arguments for the tool call
env: z.any(), // Arguments for the tool call
toolName: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
// Create a closure that binds fileService and userId to processContentBlocks
const boundProcessContentBlocks = async (blocks: ToolCallContent[]) =>
processContentBlocks(blocks, ctx.fileService);
// Pass the validated params, toolName, args, and bound processContentBlocks to the service
return await mcpService.callTool({
clientParams: { ...input.params, env: input.env },
toolName: input.toolName,
argsStr: input.args,
processContentBlocks: boundProcessContentBlocks,
});
}),
validMcpServerInstallable: mcpProcedure
.input(
z.object({
deploymentOptions: z.array(z.object({}).passthrough()),
}),
)
.mutation(async ({ input }) => {
return await mcpService.checkMcpInstall(input as any);
}),
});
-43
View File
@@ -1,43 +0,0 @@
import { z } from 'zod';
import { DESKTOP_USER_ID } from '@/const/desktop';
import { TableViewerRepo } from '@/database/repositories/tableViewer';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
const pgTableProcedure = publicProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
return next({
ctx: {
tableViewerRepo: new TableViewerRepo(ctx.serverDB, DESKTOP_USER_ID),
},
});
});
export const pgTableRouter = router({
getAllTables: pgTableProcedure.query(async ({ ctx }) => {
return ctx.tableViewerRepo.getAllTables();
}),
getTableData: pgTableProcedure
.input(
z.object({
page: z.number(),
pageSize: z.number(),
tableName: z.string(),
}),
)
.query(async ({ input, ctx }) => {
return ctx.tableViewerRepo.getTableData(input.tableName, {
page: input.page,
pageSize: input.pageSize,
});
}),
getTableDetails: pgTableProcedure
.input(
z.object({
tableName: z.string(),
}),
)
.query(async ({ input, ctx }) => {
return ctx.tableViewerRepo.getTableDetails(input.tableName);
}),
});
+1 -1
View File
@@ -50,7 +50,7 @@ class LocalFileService {
}
async openLocalFolder(params: OpenLocalFolderParams) {
return ensureElectronIpc().localSystem.handleOpenLocalFile(params);
return ensureElectronIpc().localSystem.handleOpenLocalFolder(params);
}
async moveLocalFiles(params: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
+8 -1
View File
@@ -12,6 +12,7 @@ import type { ElectronStore } from '../store';
* 设置操作
*/
export interface ElectronRemoteServerAction {
clearRemoteServerSyncError: () => void;
connectRemoteServer: (params: DataSyncConfig) => Promise<void>;
disconnectRemoteServer: () => Promise<void>;
refreshServerConfig: () => Promise<void>;
@@ -27,10 +28,15 @@ export const remoteSyncSlice: StateCreator<
[],
ElectronRemoteServerAction
> = (set, get) => ({
clearRemoteServerSyncError: () => {
set({ remoteServerSyncError: undefined }, false, 'clearRemoteServerSyncError');
},
connectRemoteServer: async (values) => {
if (values.storageMode === 'selfHost' && !values.remoteServerUrl) return;
set({ isConnectingServer: true });
get().clearRemoteServerSyncError();
try {
// 获取当前配置
const config = await remoteServerService.getRemoteServerConfig();
@@ -64,8 +70,9 @@ export const remoteSyncSlice: StateCreator<
disconnectRemoteServer: async () => {
set({ isConnectingServer: false });
get().clearRemoteServerSyncError();
try {
await remoteServerService.setRemoteServerConfig({ active: false, storageMode: 'local' });
await remoteServerService.setRemoteServerConfig({ active: false, storageMode: 'cloud' });
// 更新表单URL为空
set({ dataSyncConfig: initialState.dataSyncConfig });
// 刷新状态
+1 -1
View File
@@ -30,7 +30,7 @@ export interface ElectronState {
export const initialState: ElectronState = {
appState: {},
dataSyncConfig: { storageMode: 'local' },
dataSyncConfig: { storageMode: 'cloud' },
desktopHotkeys: {},
isAppStateInit: false,
isConnectingServer: false,
+8
View File
@@ -12,3 +12,11 @@ declare module 'antd-style' {
declare module 'styled-components' {
export interface DefaultTheme extends AntdToken, LobeCustomToken {}
}
declare global {
interface Window {
lobeEnv?: {
darwinMajorVersion?: number;
};
}
}