♻️ refactor: refactor market sdk into market servers (#11604)

* refactor: refactor market sdk into market servers

* fix: fixed the test failed problem
This commit is contained in:
Shinji-Li
2026-01-19 13:25:54 +08:00
committed by GitHub
parent 2c1af8a728
commit 858cc20a5b
21 changed files with 635 additions and 516 deletions

View File

@@ -1,7 +1,6 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { type NextRequest, NextResponse } from 'next/server';
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
type RouteContext = {
params: Promise<{
@@ -9,18 +8,6 @@ type RouteContext = {
}>;
};
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const extractAccessToken = (req: NextRequest) => {
const authorization = req.headers.get('authorization');
if (!authorization) return undefined;
const [scheme, token] = authorization.split(' ');
if (scheme?.toLowerCase() !== 'bearer' || !token) return undefined;
return token;
};
const methodNotAllowed = (methods: string[]) =>
NextResponse.json(
{
@@ -55,14 +42,8 @@ const notFound = (reason: string) =>
);
const handleAgent = async (req: NextRequest, segments: string[]) => {
const accessToken = extractAccessToken(req);
const trustedClientToken = await getTrustedClientTokenForSession();
const market = new MarketSDK({
accessToken,
baseURL: MARKET_BASE_URL,
trustedClientToken,
});
const marketService = await MarketService.createFromRequest(req);
const market = marketService.market;
if (segments.length === 0) {
return notFound('Missing agent action.');
@@ -94,17 +75,6 @@ const handleAgent = async (req: NextRequest, segments: string[]) => {
if (action === 'own') {
if (req.method !== 'GET') return methodNotAllowed(['GET']);
if (!accessToken) {
return NextResponse.json(
{
error: 'unauthorized',
message: 'Authentication required to get own agents',
status: 'error',
},
{ status: 401 },
);
}
try {
// Parse query parameters from the request URL
const url = new URL(req.url);

View File

@@ -1,15 +1,15 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { type NextRequest, NextResponse } from 'next/server';
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
type RouteContext = {
params: Promise<{
segments?: string[];
}>;
};
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const ALLOWED_ENDPOINTS = new Set(['handoff', 'token', 'userinfo']);
const ensureEndpoint = (segments?: string[]) => {
@@ -44,9 +44,8 @@ const methodNotAllowed = (allowed: string[]) =>
);
const handleProxy = async (req: NextRequest, context: RouteContext) => {
const market = new MarketSDK({
baseURL: MARKET_BASE_URL,
});
const marketService = new MarketService();
const market = marketService.market;
const { segments } = await context.params;
const endpointResult = ensureEndpoint(segments);

View File

@@ -1,7 +1,6 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { type NextRequest, NextResponse } from 'next/server';
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
type RouteContext = {
params: Promise<{
@@ -9,19 +8,6 @@ type RouteContext = {
}>;
};
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
/**
* Helper to get authorization header
*/
const getAccessToken = (req: NextRequest): string | undefined => {
const authHeader = req.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return undefined;
};
/**
* POST /market/social/follow
* POST /market/social/unfollow
@@ -34,22 +20,9 @@ const getAccessToken = (req: NextRequest): string | undefined => {
export const POST = async (req: NextRequest, context: RouteContext) => {
const { segments = [] } = await context.params;
const action = segments[0];
const accessToken = getAccessToken(req);
const trustedClientToken = await getTrustedClientTokenForSession();
const market = new MarketSDK({
accessToken,
baseURL: MARKET_BASE_URL,
trustedClientToken,
});
// Only require accessToken if trusted client token is not available
if (!accessToken && !trustedClientToken) {
return NextResponse.json(
{ error: 'unauthorized', message: 'Access token required' },
{ status: 401 },
);
}
const marketService = await MarketService.createFromRequest(req);
const market = marketService.market;
try {
const body = await req.json();
@@ -135,14 +108,9 @@ export const POST = async (req: NextRequest, context: RouteContext) => {
export const GET = async (req: NextRequest, context: RouteContext) => {
const { segments = [] } = await context.params;
const action = segments[0];
const accessToken = getAccessToken(req);
const trustedClientToken = await getTrustedClientTokenForSession();
const market = new MarketSDK({
accessToken,
baseURL: MARKET_BASE_URL,
trustedClientToken,
});
const marketService = await MarketService.createFromRequest(req);
const market = marketService.market;
const url = new URL(req.url);
const limit = url.searchParams.get('pageSize') || url.searchParams.get('limit');
@@ -158,9 +126,6 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
// Follow queries
case 'follow-status': {
const targetUserId = Number(segments[1]);
if (!accessToken && !trustedClientToken) {
return NextResponse.json({ isFollowing: false, isMutual: false });
}
const result = await market.follows.checkFollowStatus(targetUserId);
return NextResponse.json(result);
}
@@ -193,9 +158,6 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
case 'favorite-status': {
const targetType = segments[1] as 'agent' | 'plugin';
const targetIdOrIdentifier = segments[2];
if (!accessToken && !trustedClientToken) {
return NextResponse.json({ isFavorited: false });
}
// SDK accepts both number (targetId) and string (identifier)
const isNumeric = /^\d+$/.test(targetIdOrIdentifier);
const targetValue = isNumeric ? Number(targetIdOrIdentifier) : targetIdOrIdentifier;
@@ -204,12 +166,6 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
}
case 'favorites': {
if (!accessToken) {
return NextResponse.json(
{ error: 'unauthorized', message: 'Access token required' },
{ status: 401 },
);
}
const result = await market.favorites.getMyFavorites(paginationParams);
return NextResponse.json(result);
}
@@ -236,9 +192,6 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
case 'like-status': {
const targetType = segments[1] as 'agent' | 'plugin';
const targetIdOrIdentifier = segments[2];
if (!accessToken && !trustedClientToken) {
return NextResponse.json({ isLiked: false });
}
const isNumeric = /^\d+$/.test(targetIdOrIdentifier);
const targetValue = isNumeric ? Number(targetIdOrIdentifier) : targetIdOrIdentifier;
const result = await market.likes.checkLike(targetType, targetValue as number);

View File

@@ -1,7 +1,6 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { type NextRequest, NextResponse } from 'next/server';
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
type RouteContext = {
params: Promise<{
@@ -9,8 +8,6 @@ type RouteContext = {
}>;
};
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
/**
* GET /market/user/[username]
*
@@ -20,12 +17,9 @@ const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://mark
export const GET = async (req: NextRequest, context: RouteContext) => {
const { username } = await context.params;
const decodedUsername = decodeURIComponent(username);
const trustedClientToken = await getTrustedClientTokenForSession();
const market = new MarketSDK({
baseURL: MARKET_BASE_URL,
trustedClientToken,
});
const marketService = await MarketService.createFromRequest(req);
const market = marketService.market;
try {
const response = await market.user.getUserInfo(decodedUsername);

View File

@@ -1,19 +1,6 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { type NextRequest, NextResponse } from 'next/server';
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const extractAccessToken = (req: NextRequest) => {
const authorization = req.headers.get('authorization');
if (!authorization) return undefined;
const [scheme, token] = authorization.split(' ');
if (scheme?.toLowerCase() !== 'bearer' || !token) return undefined;
return token;
};
import { MarketService } from '@/server/services/market';
/**
* PUT /market/user/me
@@ -28,26 +15,8 @@ const extractAccessToken = (req: NextRequest) => {
* - meta?: { description?: string; socialLinks?: { github?: string; twitter?: string; website?: string } }
*/
export const PUT = async (req: NextRequest) => {
const accessToken = extractAccessToken(req);
const trustedClientToken = await getTrustedClientTokenForSession();
const market = new MarketSDK({
accessToken,
baseURL: MARKET_BASE_URL,
trustedClientToken,
});
// Only require accessToken if trusted client token is not available
if (!accessToken && !trustedClientToken) {
return NextResponse.json(
{
error: 'unauthorized',
message: 'Authentication required to update user profile',
status: 'error',
},
{ status: 401 },
);
}
const marketService = await MarketService.createFromRequest(req);
const market = marketService.market;
try {
const payload = await req.json();

View File

@@ -1,6 +1,5 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { generateTrustedClientToken, type TrustedClientUserInfo } from '@/libs/trusted-client';
import { type TrustedClientUserInfo } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
import { trpc } from '../init';
@@ -10,53 +9,45 @@ interface ContextWithMarketUserInfo {
}
/**
* Middleware that initializes MarketSDK with proper authentication.
* Middleware that initializes MarketService with proper authentication.
* This requires marketUserInfo middleware to be applied first.
*
* Provides:
* - ctx.marketSDK: Initialized MarketSDK instance with trustedClientToken and optional accessToken
* - ctx.trustedClientToken: The generated trusted client token (if available)
* - ctx.marketSDK: MarketSDK instance for backward compatibility
* - ctx.marketService: MarketService instance (recommended)
*/
export const marketSDK = trpc.middleware(async (opts) => {
const ctx = opts.ctx as ContextWithMarketUserInfo;
// Generate trusted client token if user info is available
const trustedClientToken = ctx.marketUserInfo
? generateTrustedClientToken(ctx.marketUserInfo)
: undefined;
// Initialize MarketSDK with both authentication methods
const market = new MarketSDK({
// Initialize MarketService with authentication
const marketService = new MarketService({
accessToken: ctx.marketAccessToken,
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
trustedClientToken,
userInfo: ctx.marketUserInfo,
});
return opts.next({
ctx: {
marketSDK: market,
trustedClientToken,
marketSDK: marketService.market, // Backward compatibility
marketService, // New recommended way
},
});
});
/**
* Middleware that requires authentication for Market API access.
* This middleware ensures that either accessToken or trustedClientToken is available.
* This middleware ensures that either accessToken or marketUserInfo is available.
* It should be used after marketUserInfo and marketSDK middlewares.
*
* Throws UNAUTHORIZED error if neither authentication method is available.
*/
export const requireMarketAuth = trpc.middleware(async (opts) => {
const ctx = opts.ctx as ContextWithMarketUserInfo & {
trustedClientToken?: string;
};
const ctx = opts.ctx as ContextWithMarketUserInfo;
// Check if any authentication is available
const hasAccessToken = !!ctx.marketAccessToken;
const hasTrustedToken = !!ctx.trustedClientToken;
const hasUserInfo = !!ctx.marketUserInfo;
if (!hasAccessToken && !hasTrustedToken) {
if (!hasAccessToken && !hasUserInfo) {
const { TRPCError } = await import('@trpc/server');
throw new TRPCError({
code: 'UNAUTHORIZED',

View File

@@ -3,7 +3,7 @@ import { buildTrustedClientPayload, createTrustedClientToken } from '@lobehub/ma
import { appEnv } from '@/envs/app';
export interface TrustedClientUserInfo {
email: string;
email?: string;
name?: string;
userId: string;
}

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/middleware';
import { DiscoverService } from '@/server/services/discover';
import { MarketService } from '@/server/services/market';
import {
AssistantSorts,
McpConnectionType,
@@ -37,6 +38,10 @@ const marketProcedure = publicProcedure
accessToken: ctx.marketAccessToken,
userInfo: ctx.marketUserInfo,
}),
marketService: new MarketService({
accessToken: ctx.marketAccessToken,
userInfo: ctx.marketUserInfo,
}),
},
});
});

View File

@@ -1,18 +1,29 @@
import { MarketSDK } from '@lobehub/market-sdk';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import { z } from 'zod';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/middleware';
import { generateTrustedClientToken } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
const log = debug('lambda-router:market:oidc');
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
// OIDC procedures are public (used during authentication flow)
const oidcProcedure = publicProcedure.use(serverDatabase).use(marketUserInfo);
const oidcProcedure = publicProcedure
.use(serverDatabase)
.use(marketUserInfo)
.use(async ({ ctx, next }) => {
// Initialize MarketService (may be without auth for public endpoints)
const marketService = new MarketService({
userInfo: ctx.marketUserInfo,
});
return next({
ctx: {
marketService,
},
});
});
export const oidcRouter = router({
/**
@@ -28,19 +39,11 @@ export const oidcRouter = router({
redirectUri: z.string(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
log('exchangeAuthorizationCode input: %O', { ...input, code: '[REDACTED]' });
const market = new MarketSDK({ baseURL: MARKET_BASE_URL });
try {
const response = await market.auth.exchangeOAuthToken({
clientId: input.clientId,
code: input.code,
codeVerifier: input.codeVerifier,
grantType: 'authorization_code',
redirectUri: input.redirectUri,
});
const response = await ctx.marketService.exchangeAuthorizationCode(input);
return response;
} catch (error) {
log('Error exchanging authorization code: %O', error);
@@ -56,23 +59,23 @@ export const oidcRouter = router({
* Get OAuth handoff information
* GET /market/oidc/handoff?id=xxx
*/
getOAuthHandoff: oidcProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
log('getOAuthHandoff input: %O', input);
getOAuthHandoff: oidcProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
log('getOAuthHandoff input: %O', input);
const market = new MarketSDK({ baseURL: MARKET_BASE_URL });
try {
const handoff = await market.auth.getOAuthHandoff(input.id);
return handoff;
} catch (error) {
log('Error getting OAuth handoff: %O', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: error instanceof Error ? error.message : 'Failed to get OAuth handoff',
});
}
}),
try {
const handoff = await ctx.marketService.getOAuthHandoff(input.id);
return handoff;
} catch (error) {
log('Error getting OAuth handoff: %O', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: error instanceof Error ? error.message : 'Failed to get OAuth handoff',
});
}
}),
/**
* Get user info from token or trusted client
@@ -83,37 +86,17 @@ export const oidcRouter = router({
.mutation(async ({ input, ctx }) => {
log('getUserInfo input: token=%s', input.token ? '[REDACTED]' : 'undefined');
const market = new MarketSDK({ baseURL: MARKET_BASE_URL });
try {
// If token is provided, use it
if (input.token) {
const response = await market.auth.getUserInfo(input.token);
const response = await ctx.marketService.getUserInfo(input.token);
return response;
}
// Otherwise, try to use trustedClientToken
if (ctx.marketUserInfo) {
const trustedClientToken = generateTrustedClientToken(ctx.marketUserInfo);
if (trustedClientToken) {
const userInfoUrl = `${MARKET_BASE_URL}/lobehub-oidc/userinfo`;
const response = await fetch(userInfoUrl, {
headers: {
'Content-Type': 'application/json',
'x-lobe-trust-token': trustedClientToken,
},
method: 'GET',
});
if (!response.ok) {
throw new Error(
`Failed to fetch user info: ${response.status} ${response.statusText}`,
);
}
return await response.json();
}
const response = await ctx.marketService.getUserInfoWithTrustedClient();
return response;
}
throw new TRPCError({
@@ -143,15 +126,12 @@ export const oidcRouter = router({
refreshToken: z.string(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
log('refreshToken input: %O', { ...input, refreshToken: '[REDACTED]' });
const market = new MarketSDK({ baseURL: MARKET_BASE_URL });
try {
const response = await market.auth.exchangeOAuthToken({
clientId: input.clientId,
grantType: 'refresh_token',
const response = await ctx.marketService.refreshToken({
clientId: input.clientId || '',
refreshToken: input.refreshToken,
});
return response;

View File

@@ -1,4 +1,4 @@
import { type CodeInterpreterToolName, MarketSDK } from '@lobehub/market-sdk';
import { type CodeInterpreterToolName } from '@lobehub/market-sdk';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import { sha256 } from 'js-sha256';
@@ -8,10 +8,11 @@ import { type ToolCallContent } from '@/libs/mcp';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { marketUserInfo, serverDatabase, telemetry } from '@/libs/trpc/lambda/middleware';
import { marketSDK, requireMarketAuth } from '@/libs/trpc/lambda/middleware/marketSDK';
import { generateTrustedClientToken, isTrustedClientEnabled } from '@/libs/trusted-client';
import { isTrustedClientEnabled } from '@/libs/trusted-client';
import { FileS3 } from '@/server/modules/S3';
import { DiscoverService } from '@/server/services/discover';
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
import {
contentBlocksToString,
processContentBlocks,
@@ -37,6 +38,10 @@ const marketToolProcedure = authedProcedure
userInfo: ctx.marketUserInfo,
}),
fileService: new FileService(ctx.serverDB, ctx.userId),
marketService: new MarketService({
accessToken: ctx.marketAccessToken,
userInfo: ctx.marketUserInfo,
}),
userModel,
},
});
@@ -79,7 +84,6 @@ const metaSchema = z
// Schema for code interpreter tool call request
const callCodeInterpreterToolSchema = z.object({
marketAccessToken: z.string().optional(),
params: z.record(z.any()),
toolName: z.string(),
topicId: z.string(),
@@ -224,27 +228,17 @@ export const marketRouter = router({
callCodeInterpreterTool: marketToolProcedure
.input(callCodeInterpreterToolSchema)
.mutation(async ({ input, ctx }) => {
const { toolName, params, userId, topicId, marketAccessToken } = input;
const { toolName, params, userId, topicId } = input;
log('Calling cloud code interpreter tool: %s with params: %O', toolName, {
params,
topicId,
userId,
});
log('Market access token available: %s', marketAccessToken ? 'yes' : 'no');
// Generate trusted client token if user info is available
const trustedClientToken = ctx.marketUserInfo
? generateTrustedClientToken(ctx.marketUserInfo)
: undefined;
try {
// Initialize MarketSDK with market access token and trusted client token
const market = new MarketSDK({
accessToken: marketAccessToken,
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
trustedClientToken,
});
// Use marketService from ctx
const market = ctx.marketService.market;
// Call market-sdk's runBuildInTool
const response = await market.plugins.runBuildInTool(
@@ -555,34 +549,8 @@ export const marketRouter = router({
const uploadUrl = await s3.createPreSignedUrl(key);
log('Generated upload URL for key: %s', key);
// Step 2: Generate trusted client token if user info is available
const trustedClientToken = ctx.marketUserInfo
? generateTrustedClientToken(ctx.marketUserInfo)
: undefined;
// Only require user accessToken if trusted client is not available
let userAccessToken: string | undefined;
if (!trustedClientToken) {
const userState = await ctx.userModel.getUserState(async () => ({}));
userAccessToken = userState.settings?.market?.accessToken;
if (!userAccessToken) {
return {
error: { message: 'User access token not found. Please sign in to Market first.' },
filename,
success: false,
} as ExportAndUploadFileResult;
}
} else {
log('Using trusted client authentication for exportAndUploadFile');
}
// Initialize MarketSDK
const market = new MarketSDK({
accessToken: userAccessToken,
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
trustedClientToken,
});
// Step 2: Use MarketService from ctx
const market = ctx.marketService.market;
// Step 3: Call sandbox's exportFile tool with the upload URL
const response = await market.plugins.runBuildInTool(

View File

@@ -3,6 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentRuntimeService } from './AgentRuntimeService';
import type { AgentExecutionParams, OperationCreationParams, StartExecutionParams } from './types';
// Mock trusted client to avoid server-side env access
vi.mock('@/libs/trusted-client', () => ({
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined),
isTrustedClientEnabled: vi.fn().mockReturnValue(false),
}));
// Mock database and models
vi.mock('@/database/models/message', () => ({
MessageModel: vi.fn().mockImplementation(() => ({

View File

@@ -152,7 +152,7 @@ export class AgentRuntimeService {
// Initialize ToolExecutionService with dependencies
const pluginGatewayService = new PluginGatewayService();
const builtinToolsExecutor = new BuiltinToolsExecutor();
const builtinToolsExecutor = new BuiltinToolsExecutor(db, userId);
this.toolExecutionService = new ToolExecutionService({
builtinToolsExecutor,

View File

@@ -2,6 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AiAgentService } from '../index';
// Mock trusted client to avoid server-side env access
vi.mock('@/libs/trusted-client', () => ({
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined),
isTrustedClientEnabled: vi.fn().mockReturnValue(false),
}));
// Mock MessageModel to capture create calls
const mockMessageCreate = vi.fn();

View File

@@ -3,6 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AiAgentService } from '../index';
// Mock trusted client to avoid server-side env access
vi.mock('@/libs/trusted-client', () => ({
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined),
isTrustedClientEnabled: vi.fn().mockReturnValue(false),
}));
// Mock ThreadModel
const mockThreadModel = {
create: vi.fn(),

View File

@@ -11,7 +11,6 @@ import type {
} from '@lobechat/types';
import { ThreadStatus, ThreadType } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
import { MarketSDK } from '@lobehub/market-sdk';
import debug from 'debug';
import { LOADING_FLAT } from '@/const/message';
@@ -20,8 +19,6 @@ import { MessageModel } from '@/database/models/message';
import { PluginModel } from '@/database/models/plugin';
import { ThreadModel } from '@/database/models/thread';
import { TopicModel } from '@/database/models/topic';
import { UserModel } from '@/database/models/user';
import { generateTrustedClientToken } from '@/libs/trusted-client';
import {
type ServerAgentToolsContext,
createServerAgentToolsEngine,
@@ -30,6 +27,7 @@ import {
import { AgentService } from '@/server/services/agent';
import { AgentRuntimeService } from '@/server/services/agentRuntime';
import type { StepLifecycleCallbacks } from '@/server/services/agentRuntime/types';
import { MarketService } from '@/server/services/market';
const log = debug('lobe-server:ai-agent-service');
@@ -88,6 +86,7 @@ export class AiAgentService {
private readonly threadModel: ThreadModel;
private readonly topicModel: TopicModel;
private readonly agentRuntimeService: AgentRuntimeService;
private readonly marketService: MarketService;
constructor(db: LobeChatDatabase, userId: string) {
this.userId = userId;
@@ -99,6 +98,7 @@ export class AiAgentService {
this.threadModel = new ThreadModel(db, userId);
this.topicModel = new TopicModel(db, userId);
this.agentRuntimeService = new AgentRuntimeService(db, userId);
this.marketService = new MarketService({ userInfo: { userId } });
}
/**
@@ -190,7 +190,12 @@ export class AiAgentService {
};
// 5. Fetch LobeHub Skills manifests (temporary solution until LOBE-3517 is implemented)
const lobehubSkillManifests = await this.fetchLobehubSkillManifests();
let lobehubSkillManifests: LobeToolManifest[] = [];
try {
lobehubSkillManifests = await this.marketService.getLobehubSkillManifests();
} catch (error) {
log('execAgent: failed to fetch lobehub skill manifests: %O', error);
}
log('execAgent: got %d lobehub skill manifests', lobehubSkillManifests.length);
// 6. Create tools using Server AgentToolsEngine
@@ -736,98 +741,6 @@ export class AiAgentService {
};
}
/**
* Fetch LobeHub Skills manifests from Market API
* This is a temporary solution until LOBE-3517 is implemented (store skills in DB)
*/
private async fetchLobehubSkillManifests(): Promise<LobeToolManifest[]> {
try {
// 1. Get user info for trusted client token
const user = await UserModel.findById(this.db, this.userId);
if (!user?.email) {
log('fetchLobehubSkillManifests: user email not found, skipping');
return [];
}
// 2. Generate trusted client token
const trustedClientToken = generateTrustedClientToken({
email: user.email,
name: user.fullName || user.firstName || undefined,
userId: this.userId,
});
if (!trustedClientToken) {
log('fetchLobehubSkillManifests: trusted client not configured, skipping');
return [];
}
// 3. Create MarketSDK instance
const marketSDK = new MarketSDK({
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
trustedClientToken,
});
// 4. Get user's connected skills
const { connections } = await marketSDK.connect.listConnections();
if (!connections || connections.length === 0) {
log('fetchLobehubSkillManifests: no connected skills found');
return [];
}
log('fetchLobehubSkillManifests: found %d connected skills', connections.length);
// 5. Fetch tools for each connection and build manifests
const manifests: LobeToolManifest[] = [];
for (const connection of connections) {
try {
// Connection returns providerId (e.g., 'twitter', 'linear'), not numeric id
const providerId = (connection as any).providerId;
if (!providerId) {
log('fetchLobehubSkillManifests: connection missing providerId: %O', connection);
continue;
}
const providerName =
(connection as any).providerName || (connection as any).name || providerId;
const icon = (connection as any).icon;
const { tools } = await marketSDK.skills.listTools(providerId);
if (!tools || tools.length === 0) continue;
const manifest: LobeToolManifest = {
api: tools.map((tool: any) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier: providerId,
meta: {
avatar: icon || '🔗',
description: `LobeHub Skill: ${providerName}`,
tags: ['lobehub-skill', providerId],
title: providerName,
},
type: 'builtin',
};
manifests.push(manifest);
log(
'fetchLobehubSkillManifests: built manifest for %s with %d tools',
providerId,
tools.length,
);
} catch (error) {
log('fetchLobehubSkillManifests: failed to fetch tools for connection: %O', error);
}
}
return manifests;
} catch (error) {
log('fetchLobehubSkillManifests: error fetching skills: %O', error);
return [];
}
}
/**
* Calculate total tokens from AgentState usage object
* AgentState.usage is of type Usage from @lobechat/agent-runtime

View File

@@ -62,10 +62,11 @@ import { cloneDeep, countBy, isString, merge, uniq, uniqBy } from 'es-toolkit/co
import matter from 'gray-matter';
import urlJoin from 'url-join';
import { type TrustedClientUserInfo, generateTrustedClientToken } from '@/libs/trusted-client';
import { type TrustedClientUserInfo } from '@/libs/trusted-client';
import { normalizeLocale } from '@/locales/resources';
import { AssistantStore } from '@/server/modules/AssistantStore';
import { PluginStore } from '@/server/modules/PluginStore';
import { MarketService } from '@/server/services/market';
const log = debug('lobe-server:discover');
@@ -84,18 +85,14 @@ export class DiscoverService {
constructor(options: DiscoverServiceOptions = {}) {
const { accessToken, userInfo } = options;
// Generate trusted client token if user info is available
const trustedClientToken = userInfo ? generateTrustedClientToken(userInfo) : undefined;
// Use MarketService to initialize MarketSDK
const marketService = new MarketService({ accessToken, userInfo });
this.market = marketService.market;
this.market = new MarketSDK({
accessToken,
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
trustedClientToken,
});
log(
'DiscoverService initialized with market baseURL: %s, hasTrustedToken: %s, userId: %s',
'DiscoverService initialized with market baseURL: %s, hasAuth: %s, userId: %s',
process.env.NEXT_PUBLIC_MARKET_BASE_URL,
!!trustedClientToken,
!!(accessToken || userInfo),
userInfo?.userId,
);
}
@@ -135,14 +132,12 @@ export class DiscoverService {
}
async fetchM2MToken(params: { clientId: string; clientSecret: string }) {
// 使用传入的客户端凭证创建新的 MarketSDK 实例
const tokenMarket = new MarketSDK({
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
clientId: params.clientId,
clientSecret: params.clientSecret,
// Use MarketService with M2M credentials
const marketService = new MarketService({
clientCredentials: params,
});
const tokenInfo = await tokenMarket.fetchM2MToken();
const tokenInfo = await marketService.fetchM2MToken();
return {
accessToken: tokenInfo.accessToken,

View File

@@ -1,109 +0,0 @@
import { type LobeChatDatabase } from '@lobechat/database';
import { MarketSDK } from '@lobehub/market-sdk';
import debug from 'debug';
import { UserModel } from '@/database/models/user';
import { generateTrustedClientToken } from '@/libs/trusted-client';
const log = debug('lobe-server:lobehub-skill-service');
export interface LobehubSkillExecuteParams {
args: Record<string, any>;
provider: string;
toolName: string;
}
export interface LobehubSkillExecuteResult {
content: string;
error?: { code: string; message?: string };
success: boolean;
}
export class LobehubSkillService {
private db: LobeChatDatabase;
private userId: string;
private marketSDK?: MarketSDK;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
/**
* Initialize MarketSDK with trusted client token
*/
private async getMarketSDK(): Promise<MarketSDK | null> {
if (this.marketSDK) return this.marketSDK;
try {
const user = await UserModel.findById(this.db, this.userId);
if (!user?.email) {
log('getMarketSDK: user email not found');
return null;
}
const trustedClientToken = generateTrustedClientToken({
email: user.email,
name: user.fullName || user.firstName || undefined,
userId: this.userId,
});
if (!trustedClientToken) {
log('getMarketSDK: trusted client not configured');
return null;
}
this.marketSDK = new MarketSDK({
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
trustedClientToken,
});
return this.marketSDK;
} catch (error) {
log('getMarketSDK: error creating SDK: %O', error);
return null;
}
}
/**
* Execute a LobeHub Skill tool
*/
async execute(params: LobehubSkillExecuteParams): Promise<LobehubSkillExecuteResult> {
const { provider, toolName, args } = params;
log('execute: %s/%s with args: %O', provider, toolName, args);
const sdk = await this.getMarketSDK();
if (!sdk) {
return {
content:
'MarketSDK not available. Please ensure you are authenticated with LobeHub Market.',
error: { code: 'MARKET_SDK_NOT_AVAILABLE' },
success: false,
};
}
try {
const response = await sdk.skills.callTool(provider, {
args,
tool: toolName,
});
log('execute: response: %O', response);
return {
content: typeof response.data === 'string' ? response.data : JSON.stringify(response.data),
success: response.success,
};
} catch (error) {
const err = error as Error;
console.error('LobehubSkillService.execute error %s/%s: %O', provider, toolName, err);
return {
content: err.message,
error: { code: 'LOBEHUB_SKILL_ERROR', message: err.message },
success: false,
};
}
}
}

View File

@@ -0,0 +1,485 @@
import type { LobeToolManifest } from '@lobechat/context-engine';
import { MarketSDK } from '@lobehub/market-sdk';
import debug from 'debug';
import { type NextRequest } from 'next/server';
import {
type TrustedClientUserInfo,
generateTrustedClientToken,
getTrustedClientTokenForSession,
} from '@/libs/trusted-client';
const log = debug('lobe-server:market-service');
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
// ============================== Helper Functions ==============================
/**
* Extract access token from Authorization header
*/
export function extractAccessToken(req: NextRequest): string | undefined {
const authHeader = req.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return undefined;
}
export interface LobehubSkillExecuteParams {
args: Record<string, any>;
provider: string;
toolName: string;
}
export interface LobehubSkillExecuteResult {
content: string;
error?: { code: string; message?: string };
success: boolean;
}
export interface MarketServiceOptions {
/** Access token from OIDC flow (user token) */
accessToken?: string;
/** Client credentials for M2M authentication */
clientCredentials?: {
clientId: string;
clientSecret: string;
};
/** Pre-generated trusted client token (alternative to userInfo) */
trustedClientToken?: string;
/** User info for generating trusted client token */
userInfo?: TrustedClientUserInfo;
}
/**
* Market Service
*
* Provides a unified interface to MarketSDK with business logic encapsulation.
* This service wraps MarketSDK methods to avoid repetition across the codebase.
*
* Usage:
* ```typescript
* // From Next.js request (API Routes) - recommended
* const marketService = await MarketService.createFromRequest(req);
* await marketService.submitFeedback({ ... });
*
* // With user authentication
* const service = new MarketService({ accessToken, userInfo });
*
* // With trusted client only
* const service = new MarketService({ userInfo });
*
* // M2M authentication
* const service = new MarketService({ clientCredentials: { clientId, clientSecret } });
*
* // Public endpoints (no auth)
* const service = new MarketService();
* ```
*/
export class MarketService {
market: MarketSDK;
constructor(options: MarketServiceOptions = {}) {
const { accessToken, userInfo, clientCredentials, trustedClientToken } = options;
// Use provided trustedClientToken or generate from userInfo
const resolvedTrustedClientToken =
trustedClientToken || (userInfo ? generateTrustedClientToken(userInfo) : undefined);
this.market = new MarketSDK({
accessToken,
baseURL: MARKET_BASE_URL,
clientId: clientCredentials?.clientId,
clientSecret: clientCredentials?.clientSecret,
trustedClientToken: resolvedTrustedClientToken,
});
log(
'MarketService initialized: baseURL=%s, hasAccessToken=%s, hasTrustedToken=%s, hasClientCredentials=%s',
MARKET_BASE_URL,
!!accessToken,
!!resolvedTrustedClientToken,
!!clientCredentials,
);
}
// ============================== Factory Methods ==============================
/**
* Create MarketService from Next.js request (server-side only)
* Extracts accessToken from Authorization header and trustedClientToken from session
*/
static async createFromRequest(req: NextRequest): Promise<MarketService> {
const accessToken = extractAccessToken(req);
const trustedClientToken = await getTrustedClientTokenForSession();
return new MarketService({
accessToken,
trustedClientToken,
});
}
// ============================== Feedback Methods ==============================
/**
* Submit feedback to LobeHub
*/
async submitFeedback(params: {
clientInfo?: {
language?: string;
timezone?: string;
url?: string;
userAgent?: string;
};
email?: string;
message: string;
screenshotUrl?: string;
title: string;
}) {
const { title, message, email, screenshotUrl, clientInfo } = params;
// Build message with screenshot if available
let feedbackMessage = message;
if (screenshotUrl) {
feedbackMessage += `\n\n**Screenshot**: ${screenshotUrl}`;
}
return this.market.feedback.submitFeedback({
clientInfo,
email: email || '',
message: feedbackMessage,
title,
});
}
// ============================== Auth Methods ==============================
/**
* Exchange OAuth authorization code for tokens
*/
async exchangeAuthorizationCode(params: {
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
}) {
return this.market.auth.exchangeOAuthToken({
clientId: params.clientId,
code: params.code,
codeVerifier: params.codeVerifier,
grantType: 'authorization_code',
redirectUri: params.redirectUri,
});
}
/**
* Get OAuth handoff information
*/
async getOAuthHandoff(id: string) {
return this.market.auth.getOAuthHandoff(id);
}
/**
* Get user info from token
*/
async getUserInfo(token: string) {
return this.market.auth.getUserInfo(token);
}
/**
* Get user info with trusted client token (server-side)
*/
async getUserInfoWithTrustedClient() {
const userInfoUrl = `${MARKET_BASE_URL}/lobehub-oidc/userinfo`;
const response = await fetch(userInfoUrl, {
// @ts-ignore
headers: this.market.headers,
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
return response.json();
}
/**
* Refresh OAuth token
*/
async refreshToken(params: { clientId: string; refreshToken: string }) {
return this.market.auth.exchangeOAuthToken({
clientId: params.clientId,
grantType: 'refresh_token',
refreshToken: params.refreshToken,
});
}
// ============================== Client Methods ==============================
/**
* Register client for M2M authentication
*/
async registerClient(params: {
clientName: string;
clientType: string;
deviceId: string;
platform?: string;
version?: string;
}) {
// @ts-ignore
return this.market.registerClient(params);
}
/**
* Fetch M2M token with client credentials
*/
async fetchM2MToken() {
return this.market.fetchM2MToken();
}
// ============================== Skills Methods ==============================
/**
* List available tools for a provider
*/
async listSkillTools(providerId: string) {
return this.market.skills.listTools(providerId);
}
/**
* Call a skill tool
*/
async callSkillTool(provider: string, params: { args: Record<string, any>; tool: string }) {
return this.market.skills.callTool(provider, params);
}
/**
* List user's connected skills
*/
async listSkillConnections() {
return this.market.connect.listConnections();
}
// ============================== Plugin Methods ==============================
/**
* Call cloud MCP endpoint
*/
async callCloudMcpEndpoint(
params: {
apiParams: Record<string, any>;
identifier: string;
toolName: string;
},
options?: {
headers?: Record<string, string>;
},
) {
return this.market.plugins.callCloudGateway(params, options);
}
/**
* Get plugin manifest
*/
async getPluginManifest(params: {
identifier: string;
install?: boolean;
locale?: string;
version?: string;
}) {
return this.market.plugins.getPluginManifest(params);
}
/**
* Report plugin installation
*/
async reportPluginInstallation(params: any) {
return this.market.plugins.reportInstallation(params);
}
/**
* Report plugin call
*/
async reportPluginCall(params: any) {
return this.market.plugins.reportCall(params);
}
/**
* Create plugin event
*/
async createPluginEvent(params: any) {
return this.market.plugins.createEvent(params);
}
// ============================== Agent Methods ==============================
/**
* Get agent detail
*/
async getAgentDetail(identifier: string, options?: { locale?: string; version?: string }) {
return this.market.agents.getAgentDetail(identifier, options);
}
/**
* Get agent list
*/
async getAgentList(params?: any) {
return this.market.agents.getAgentList(params);
}
/**
* Increase agent install count
*/
async increaseAgentInstallCount(identifier: string) {
return this.market.agents.increaseInstallCount(identifier);
}
/**
* Create agent event
*/
async createAgentEvent(params: any) {
return this.market.agents.createEvent(params);
}
// ============================== Agent Group Methods ==============================
/**
* Get agent group detail
*/
async getAgentGroupDetail(identifier: string, options?: { locale?: string; version?: number }) {
return this.market.agentGroups.getAgentGroupDetail(identifier, options);
}
/**
* Get agent group list
*/
async getAgentGroupList(params?: any) {
return this.market.agentGroups.getAgentGroupList(params);
}
// ============================== User Methods ==============================
/**
* Get user profile by username
*/
async getUserProfile(username: string, options?: { locale?: string }) {
return this.market.user.getUserInfo(username, options);
}
// ============================== Skills Methods ==============================
/**
* Execute a LobeHub Skill tool
* @param params - The skill execution parameters (provider, toolName, args)
* @returns Execution result with content and success status
*/
async executeLobehubSkill(params: LobehubSkillExecuteParams): Promise<LobehubSkillExecuteResult> {
const { provider, toolName, args } = params;
log('executeLobehubSkill: %s/%s with args: %O', provider, toolName, args);
try {
const response = await this.market.skills.callTool(provider, {
args,
tool: toolName,
});
log('executeLobehubSkill: response: %O', response);
return {
content: typeof response.data === 'string' ? response.data : JSON.stringify(response.data),
success: response.success,
};
} catch (error) {
const err = error as Error;
console.error('MarketService.executeLobehubSkill error %s/%s: %O', provider, toolName, err);
return {
content: err.message,
error: { code: 'LOBEHUB_SKILL_ERROR', message: err.message },
success: false,
};
}
}
/**
* Fetch LobeHub Skills manifests from Market API
* Gets user's connected skills and builds tool manifests for agent execution
*
* @returns Array of tool manifests for connected skills
*/
async getLobehubSkillManifests(): Promise<LobeToolManifest[]> {
try {
// 1. Get user's connected skills
const { connections } = await this.market.connect.listConnections();
if (!connections || connections.length === 0) {
log('getLobehubSkillManifests: no connected skills found');
return [];
}
log('getLobehubSkillManifests: found %d connected skills', connections.length);
// 2. Fetch tools for each connection and build manifests
const manifests: LobeToolManifest[] = [];
for (const connection of connections) {
try {
// Connection returns providerId (e.g., 'twitter', 'linear'), not numeric id
const providerId = (connection as any).providerId;
if (!providerId) {
log('getLobehubSkillManifests: connection missing providerId: %O', connection);
continue;
}
const providerName =
(connection as any).providerName || (connection as any).name || providerId;
const icon = (connection as any).icon;
const { tools } = await this.market.skills.listTools(providerId);
if (!tools || tools.length === 0) continue;
const manifest: LobeToolManifest = {
api: tools.map((tool: any) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier: providerId,
meta: {
avatar: icon || '🔗',
description: `LobeHub Skill: ${providerName}`,
tags: ['lobehub-skill', providerId],
title: providerName,
},
type: 'builtin',
};
manifests.push(manifest);
log(
'getLobehubSkillManifests: built manifest for %s with %d tools',
providerId,
tools.length,
);
} catch (error) {
log('getLobehubSkillManifests: failed to fetch tools for connection: %O', error);
}
}
return manifests;
} catch (error) {
log('getLobehubSkillManifests: error fetching skills: %O', error);
return [];
}
}
// ============================== Direct SDK Access ==============================
/**
* Get MarketSDK instance for advanced usage
* Use this when you need direct access to SDK methods not wrapped by this service
*/
getSDK(): MarketSDK {
return this.market;
}
}

View File

@@ -1,13 +1,14 @@
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { WebBrowsingExecutionRuntime } from '@lobechat/builtin-tool-web-browsing/executionRuntime';
import { type LobeChatDatabase } from '@lobechat/database';
import { type ChatToolPayload } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import debug from 'debug';
import { LobehubSkillService } from '@/server/services/lobehubSkill';
import { MarketService } from '@/server/services/market';
import { SearchService } from '@/server/services/search';
import { type IToolExecutor, type ToolExecutionContext, type ToolExecutionResult } from './types';
import { type IToolExecutor, type ToolExecutionResult } from './types';
const log = debug('lobe-server:builtin-tools-executor');
@@ -16,10 +17,12 @@ const BuiltinToolServerRuntimes: Record<string, any> = {
};
export class BuiltinToolsExecutor implements IToolExecutor {
async execute(
payload: ChatToolPayload,
context: ToolExecutionContext,
): Promise<ToolExecutionResult> {
private marketService: MarketService;
constructor(db: LobeChatDatabase, userId: string) {
this.marketService = new MarketService({ userInfo: { userId } });
}
async execute(payload: ChatToolPayload): Promise<ToolExecutionResult> {
const { identifier, apiName, arguments: argsStr, source } = payload;
const args = safeParseJSON(argsStr) || {};
@@ -31,18 +34,9 @@ export class BuiltinToolsExecutor implements IToolExecutor {
args,
);
// Route LobeHub Skills to dedicated service
// Route LobeHub Skills to MarketService
if (source === 'lobehubSkill') {
if (!context.serverDB || !context.userId) {
return {
content: 'Server context not available for LobeHub Skills execution.',
error: { code: 'CONTEXT_NOT_AVAILABLE' },
success: false,
};
}
const skillService = new LobehubSkillService(context.serverDB, context.userId);
return skillService.execute({
return this.marketService.executeLobehubSkill({
args,
provider: identifier,
toolName: apiName,

View File

@@ -4,7 +4,11 @@ import debug from 'debug';
import { type MCPService } from '../mcp';
import { type PluginGatewayService } from '../pluginGateway';
import { type BuiltinToolsExecutor } from './builtin';
import { type ToolExecutionContext, type ToolExecutionResult, type ToolExecutionResultResponse } from './types';
import {
type ToolExecutionContext,
type ToolExecutionResult,
type ToolExecutionResultResponse,
} from './types';
const log = debug('lobe-server:tool-execution-service');
@@ -43,7 +47,7 @@ export class ToolExecutionService {
let data: ToolExecutionResult;
switch (typeStr) {
case 'builtin': {
data = await this.builtinToolsExecutor.execute(payload, context);
data = await this.builtinToolsExecutor.execute(payload);
break;
}

View File

@@ -5,16 +5,6 @@ import type {
ExportAndUploadFileInput,
ExportAndUploadFileResult,
} from '@/server/routers/tools/market';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/slices/settings/selectors/settings';
/**
* Get Market access token from user settings (stored by MarketAuthProvider)
*/
const getMarketAccessToken = (): string | undefined => {
const settings = settingsSelectors.currentSettings(useUserStore.getState());
return settings.market?.accessToken;
};
class CodeInterpreterService {
/**
@@ -28,10 +18,7 @@ class CodeInterpreterService {
params: Record<string, any>,
context: { topicId: string; userId: string },
): Promise<CallToolResult> {
const marketAccessToken = getMarketAccessToken();
const input: CallCodeInterpreterToolInput = {
marketAccessToken,
params,
toolName,
topicId: context.topicId,