♻️ refactor: remove chat-plugin-sdk (#13512)

* ♻️ refactor: remove @lobehub/chat-plugin-sdk dependency

Plugins have been deprecated. This removes the SDK entirely:

- Define built-in ToolManifest, ToolManifestSettings, ToolErrorType types
- Delete src/features/PluginsUI/ (plugin iframe rendering)
- Delete src/store/tool/slices/oldStore/ (deprecated plugin store)
- Delete src/server/services/pluginGateway/ (plugin gateway)
- Delete src/app/(backend)/webapi/plugin/gateway/ (plugin API route)
- Migrate all ~50 files from SDK imports to @lobechat/types
- Remove @lobehub/chat-plugin-sdk, @lobehub/chat-plugins-gateway deps
- Remove @swagger-api/apidom-reference override and patch

Fixes LOBE-6655

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: add missing getInstalledPlugins mock in customPlugin test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🔧 chore: increase Vercel build memory limit to 8192MB

The 6144MB limit was causing OOM during Vite SPA chunk rendering.
Aligned with other build commands that already use 8192MB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: unify default tool type to builtin and fix CustomRender

- Remove `invokeDefaultTypePlugin` — default type now falls through to builtin in both server and client execution paths
- Fix `CustomRender` to actually render builtin tool components via `getBuiltinRender` instead of always returning null
- Increase SPA build memory limit from 7168MB to 8192MB to fix OOM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: remove legacy plugin gateway and type-specific invocations

- Delete `runPluginApi`, `internal_callPluginApi`, `invokeMarkdownTypePlugin`, `invokeStandaloneTypePlugin`
- Remove plugin gateway endpoint (`/webapi/plugin/gateway`) from URL config
- Remove special `builtin → default` runtimeType mapping in plugin model
- Clean up unused imports and related tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: add 'builtin' to runtimeType union to fix type error

Use ToolManifestType instead of inline union for runtimeType fields
so that 'builtin' is included as a valid type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-03 00:46:19 +08:00 committed by GitHub
parent 0dc8930750
commit 3415df3715
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 310 additions and 3090 deletions

View file

@ -179,7 +179,7 @@ This system is expected to be gradually deprecated
in favor of the MCP tool system.
- Frontend calls them via the
`invokeDefaultTypePlugin` method
`invokeBuiltinTool` method
- Retrieves plugin settings and manifest,
creates authentication headers,
and sends requests to the plugin gateway

View file

@ -159,7 +159,7 @@ while (state.status !== 'done' && state.status !== 'error') {
**Plugin 工具**:传统插件体系,通过 API 网关调用。
该体系预期将逐步废弃,由 MCP 工具体系替代。
- 前端通过 `invokeDefaultTypePlugin` 方法调用
- 前端通过 `invokeBuiltinTool` 方法调用
- 获取插件设置和清单、创建认证请求头、
发送请求到插件网关

View file

@ -40,11 +40,11 @@
"build:next": "cross-env NODE_OPTIONS=--max-old-space-size=7168 bun run build:next:raw",
"build:next:raw": "next build",
"build:raw": "bun run build:spa:raw && bun run build:spa:copy && bun run build:next:raw",
"build:spa": "cross-env NODE_OPTIONS=--max-old-space-size=7168 pnpm run build:spa:raw",
"build:spa": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm run build:spa:raw",
"build:spa:copy": "tsx scripts/copySpaBuild.mts && tsx scripts/generateSpaTemplates.mts",
"build:spa:mobile": "cross-env NODE_OPTIONS=--max-old-space-size=8192 MOBILE=true vite build",
"build:spa:raw": "rm -rf public/_spa && vite build",
"build:vercel": "cross-env-shell NODE_OPTIONS=--max-old-space-size=6144 \"bun run build:raw && bun run db:migrate\"",
"build:vercel": "cross-env-shell NODE_OPTIONS=--max-old-space-size=8192 \"bun run build:raw && bun run db:migrate\"",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"clean:node_modules": "bash -lc 'set -e; echo \"Removing all node_modules...\"; rm -rf node_modules; pnpm -r exec rm -rf node_modules; rm -rf apps/desktop/node_modules; echo \"All node_modules removed.\"'",
@ -262,8 +262,6 @@
"@lobechat/web-crawler": "workspace:*",
"@lobehub/analytics": "^1.6.0",
"@lobehub/charts": "^5.0.0",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^4.5.0",
"@lobehub/icons": "^5.0.0",

View file

@ -1,6 +1,10 @@
import { DEFAULT_PREFERENCE } from '@lobechat/const';
import type { CustomPluginParams, UserAgentOnboarding, UserOnboarding } from '@lobechat/types';
import type { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import type {
CustomPluginParams,
ToolManifest,
UserAgentOnboarding,
UserOnboarding,
} from '@lobechat/types';
import { sql } from 'drizzle-orm';
import { boolean, index, jsonb, pgTable, primaryKey, text, varchar } from 'drizzle-orm/pg-core';
@ -95,7 +99,7 @@ export const userInstalledPlugins = pgTable(
identifier: text('identifier').notNull(),
type: text('type', { enum: ['plugin', 'customPlugin'] }).notNull(),
manifest: jsonb('manifest').$type<LobeChatPluginManifest>(),
manifest: jsonb('manifest').$type<ToolManifest>(),
settings: jsonb('settings'),
customParams: jsonb('custom_params').$type<CustomPluginParams>(),
source: varchar255('source'),

View file

@ -7,7 +7,6 @@
"dependencies": {
"@lobechat/python-interpreter": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/market-sdk": "0.32.2",
"@lobehub/market-types": "^1.12.3",
"model-bank": "workspace:*",

View file

@ -1,5 +1,4 @@
import type { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import type { LobeChatPluginMeta, Meta } from '@lobehub/chat-plugin-sdk/lib/types/market';
import type { ToolManifest } from '../tool/manifest';
export enum PluginCategory {
All = 'all',
@ -24,7 +23,24 @@ export enum PluginSorts {
Title = 'title',
}
export interface DiscoverPluginItem extends Omit<LobeChatPluginMeta, 'meta'>, Meta {
interface PluginMeta {
avatar: string;
description?: string;
tags?: string[];
title: string;
}
interface DiscoverPluginMeta {
author: string;
createdAt: string;
homepage: string;
identifier: string;
manifest: string;
meta: PluginMeta;
schemaVersion: number;
}
export interface DiscoverPluginItem extends Omit<DiscoverPluginMeta, 'meta'>, PluginMeta {
category?: PluginCategory;
}
@ -55,7 +71,7 @@ export interface PluginListResponse {
export type PluginSource = 'legacy' | 'market' | 'builtin';
export interface DiscoverPluginDetail extends Omit<DiscoverPluginItem, 'manifest'> {
manifest?: LobeChatPluginManifest | string;
manifest?: ToolManifest | string;
related: DiscoverPluginItem[];
/**
* Plugin source type

View file

@ -1,8 +1,8 @@
import type { IPluginErrorType } from '@lobehub/chat-plugin-sdk';
import { z } from 'zod';
import type { ILobeAgentRuntimeErrorType } from '../../agentRuntime';
import type { ErrorType } from '../../fetch';
import type { IToolErrorType } from '../../tool/error';
/**
* Chat message error object
@ -10,7 +10,7 @@ import type { ErrorType } from '../../fetch';
export interface ChatMessageError {
body?: any;
message?: string;
type: ErrorType | IPluginErrorType | ILobeAgentRuntimeErrorType;
type: ErrorType | IToolErrorType | ILobeAgentRuntimeErrorType;
}
export const ChatMessageErrorSchema = z.object({

View file

@ -1,4 +1,3 @@
import type { IPluginErrorType } from '@lobehub/chat-plugin-sdk';
import type { PartialDeep } from 'type-fest';
import { z } from 'zod';
@ -129,5 +128,5 @@ export const ChatToolPayloadSchema = z.object({
export interface ChatMessagePluginError {
body?: any;
message: string;
type: IPluginErrorType;
type: string;
}

View file

@ -5,7 +5,7 @@ import { type RuntimeStepContext } from '../stepContext';
import { type HumanInterventionConfig, type HumanInterventionPolicy } from './intervention';
import { HumanInterventionConfigSchema, HumanInterventionPolicySchema } from './intervention';
interface Meta {
export interface Meta {
/**
* avatar
* @desc Avatar of the plugin
@ -35,7 +35,7 @@ interface Meta {
title: string;
}
const MetaSchema = z.object({
export const MetaSchema = z.object({
avatar: z.string().optional(),
description: z.string().optional(),
readme: z.string().optional(),

View file

@ -0,0 +1,5 @@
export const ToolErrorType = {
PluginSettingsInvalid: 'PluginSettingsInvalid',
} as const;
export type IToolErrorType = (typeof ToolErrorType)[keyof typeof ToolErrorType];

View file

@ -1,16 +1,15 @@
import type { LobeChatPluginManifest, LobePluginType } from '@lobehub/chat-plugin-sdk';
import type { ToolManifest, ToolManifestType } from './manifest';
import type { CustomPluginParams } from './plugin';
import type { LobeToolType } from './tool';
export interface LobeTool {
customParams?: CustomPluginParams | null;
identifier: string;
manifest?: LobeChatPluginManifest | null;
manifest?: ToolManifest | null;
/**
* use for runtime
*/
runtimeType?: 'mcp' | 'default' | 'markdown' | 'standalone';
runtimeType?: ToolManifestType;
settings?: any;
// TODO: remove type and then make it required
source?: LobeToolType;
@ -21,12 +20,14 @@ export interface LobeTool {
type: LobeToolType;
}
export type LobeToolRenderType = LobePluginType | 'builtin';
export type LobeToolRenderType = ToolManifestType;
export * from './builtin';
export * from './crawler';
export * from './error';
export * from './interpreter';
export * from './intervention';
export * from './manifest';
export * from './plugin';
export * from './search';
export * from './tool';

View file

@ -0,0 +1,58 @@
import { z } from 'zod';
import { type LobeChatPluginApi, LobeChatPluginApiSchema, type Meta, MetaSchema } from './builtin';
export type ToolManifestType = 'builtin' | 'default' | 'markdown' | 'mcp' | 'standalone';
export interface ToolManifestSettings {
properties: Record<string, any>;
required?: string[];
type: 'object';
}
export const ToolManifestSettingsSchema = z.object({
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
type: z.literal('object'),
});
export interface ToolManifest {
$schema?: string;
api: LobeChatPluginApi[];
author?: string;
createdAt?: string;
gateway?: string;
homepage?: string;
identifier: string;
meta: Meta;
openapi?: string;
settings?: ToolManifestSettings;
systemRole?: string;
type?: ToolManifestType;
ui?: { height?: number; mode?: 'iframe' | 'module'; url: string; width?: number };
version?: string;
}
export const ToolManifestSchema = z.object({
$schema: z.string().optional(),
api: z.array(LobeChatPluginApiSchema),
author: z.string().optional(),
createdAt: z.string().optional(),
gateway: z.string().optional(),
homepage: z.string().optional(),
identifier: z.string(),
meta: MetaSchema,
openapi: z.string().optional(),
settings: ToolManifestSettingsSchema.optional(),
systemRole: z.string().optional(),
type: z.enum(['default', 'standalone', 'markdown', 'builtin', 'mcp']).optional(),
ui: z
.object({
height: z.number().optional(),
mode: z.enum(['iframe', 'module']).optional(),
url: z.string(),
width: z.number().optional(),
})
.optional(),
version: z.string().optional(),
});

View file

@ -1,8 +1,8 @@
import type { LobeChatPluginManifest, Meta } from '@lobehub/chat-plugin-sdk';
import type { Meta } from './builtin';
import type { ToolManifest, ToolManifestType } from './manifest';
import type { LobeToolType } from './tool';
export type PluginManifestMap = Record<string, LobeChatPluginManifest>;
export type PluginManifestMap = Record<string, ToolManifest>;
export interface CustomPluginMetadata {
avatar?: string;
@ -53,7 +53,7 @@ export interface CustomPluginParams {
export interface LobeToolCustomPlugin {
customParams?: CustomPluginParams;
identifier: string;
manifest?: LobeChatPluginManifest;
manifest?: ToolManifest;
settings?: any;
type: 'customPlugin';
}
@ -63,7 +63,7 @@ export interface InstallPluginMeta extends Partial<Meta> {
createdAt?: string;
homepage?: string;
identifier: string;
runtimeType?: 'mcp' | 'default' | 'markdown' | 'standalone' | undefined;
runtimeType?: ToolManifestType;
type: LobeToolType;
}
@ -71,3 +71,12 @@ export interface PluginInstallError {
cause?: string;
message: 'noManifest' | 'fetchError' | 'manifestInvalid' | 'urlError';
}
export interface PluginRequestPayload {
apiName: string;
arguments?: string;
identifier: string;
indexUrl?: string;
manifest?: ToolManifest;
type?: string;
}

View file

@ -15,8 +15,8 @@
},
"dependencies": {
"@lobechat/const": "workspace:*",
"@lobechat/ssrf-safe-fetch": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@vercel/functions": "^3.3.0",
"brotli-wasm": "^3.0.1",
"chroma-js": "^3.1.2",
@ -33,7 +33,6 @@
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-html": "^16.0.1",
"@lobechat/ssrf-safe-fetch": "workspace:*",
"tokenx": "^1.2.1",
"ua-parser-js": "^1.0.41",
"uuid": "^11.1.0",

View file

@ -1,11 +1,9 @@
import type { OpenAIPluginManifest } from '@lobechat/types';
import type { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { pluginManifestSchema } from '@lobehub/chat-plugin-sdk';
import type { ToolManifest } from '@lobechat/types';
import { ToolManifestSchema } from '@lobechat/types';
import { API_ENDPOINTS } from '@/services/_url';
const fetchJSON = async <T = any>(url: string, proxy = false): Promise<T> => {
// 2. Send request
let res: Response;
try {
res = await (proxy ? fetch(API_ENDPOINTS.proxy, { body: url, method: 'POST' }) : fetch(url));
@ -36,67 +34,17 @@ const fetchJSON = async <T = any>(url: string, proxy = false): Promise<T> => {
return data;
};
export const convertOpenAIManifestToLobeManifest = (
data: OpenAIPluginManifest,
): LobeChatPluginManifest => {
const manifest: LobeChatPluginManifest = {
api: [],
homepage: data.legal_info_url,
identifier: data.name_for_model,
meta: {
avatar: data.logo_url,
description: data.description_for_human,
title: data.name_for_human,
},
openapi: data.api.url,
systemRole: data.description_for_model,
type: 'default',
version: '1',
};
switch (data.auth.type) {
case 'none': {
break;
}
case 'service_http': {
manifest.settings = {
properties: {
apiAuthKey: {
default: data.auth.verification_tokens['openai'],
description: 'API Key',
format: 'password',
title: 'API Key',
type: 'string',
},
},
type: 'object',
};
break;
}
}
return manifest;
};
export const getToolManifest = async (
url?: string,
useProxy: boolean = false,
): Promise<LobeChatPluginManifest> => {
// 1. Validate plugin
): Promise<ToolManifest> => {
if (!url) {
throw new TypeError('noManifest');
}
// 2. Send request
let data = await fetchJSON<LobeChatPluginManifest>(url, useProxy);
const data = await fetchJSON<ToolManifest>(url, useProxy);
// @ts-ignore
// if there is a description_for_model, it is an OpenAI plugin
// we need convert to lobe plugin
if (data['description_for_model']) {
data = convertOpenAIManifestToLobeManifest(data as any);
}
// 3. Validate plugin file format specification
const parser = pluginManifestSchema.safeParse(data);
const parser = ToolManifestSchema.safeParse(data);
if (!parser.success) {
throw new TypeError('manifestInvalid', { cause: parser.error });

View file

@ -1,26 +0,0 @@
diff --git a/node_modules/.cache/logger/umi.log b/node_modules/.cache/logger/umi.log
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/src/parse/parsers/binary/index-node.cjs b/src/parse/parsers/binary/index-node.cjs
index a88ba4d8a0ddff6f4b545ed9368739d94f893eb2..b9b78a29e3c228e7d78e0861b38d734bdef2c854 100644
--- a/src/parse/parsers/binary/index-node.cjs
+++ b/src/parse/parsers/binary/index-node.cjs
@@ -3,7 +3,7 @@
var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault").default;
exports.__esModule = true;
exports.default = void 0;
-var _buffer = require("#buffer");
+var _buffer = require("buffer");
var _apidomCore = require("@swagger-api/apidom-core");
var _ParserError = _interopRequireDefault(require("../../../errors/ParserError.cjs"));
var _Parser = _interopRequireDefault(require("../Parser.cjs"));
diff --git a/src/parse/parsers/binary/index-node.mjs b/src/parse/parsers/binary/index-node.mjs
index 3abce8c52fd5eb7b92f33b66fccf6896ccbc89fe..4a3f957cf127a71c7f2b4dfa56512271b6f785ab 100644
--- a/src/parse/parsers/binary/index-node.mjs
+++ b/src/parse/parsers/binary/index-node.mjs
@@ -1,4 +1,4 @@
-import { Buffer } from '#buffer'; // eslint-disable-line import/order
+import { Buffer } from 'buffer'; // eslint-disable-line import/order
import { ParseResultElement, StringElement } from '@swagger-api/apidom-core';
import ParserError from "../../../errors/ParserError.mjs";
import Parser from "../Parser.mjs";

View file

@ -9,8 +9,6 @@ onlyBuiltDependencies:
- '@lobehub/editor'
overrides:
'@lobehub/chat-plugin-sdk>swagger-client': 3.36.0
'@swagger-api/apidom-reference': 1.1.0
jose: ^6.1.3
stylelint-config-clean-order: 7.0.0
pdfjs-dist: 5.4.530
@ -18,5 +16,4 @@ overrides:
react-dom: 19.2.4
patchedDependencies:
'@swagger-api/apidom-reference': patches/@swagger-api__apidom-reference.patch
'@upstash/qstash': patches/@upstash__qstash.patch

View file

@ -1,52 +0,0 @@
import { AgentRuntimeError } from '@lobechat/model-runtime';
import { ChatErrorType, TraceNameMap } from '@lobechat/types';
import { type PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { createGatewayOnEdgeRuntime } from '@lobehub/chat-plugins-gateway';
import { LOBE_CHAT_TRACE_ID } from '@/const/trace';
import { getAppConfig } from '@/envs/app';
import { LOBE_CHAT_AUTH_HEADER } from '@/envs/auth';
import { TraceClient } from '@/libs/traces';
import { parserPluginSettings } from '@/server/services/pluginGateway/settings';
import { getTracePayload } from '@/utils/trace';
const { PLUGINS_INDEX_URL: pluginsIndexUrl, PLUGIN_SETTINGS } = getAppConfig();
const defaultPluginSettings = parserPluginSettings(PLUGIN_SETTINGS);
const handler = createGatewayOnEdgeRuntime({ defaultPluginSettings, pluginsIndexUrl });
export const POST = async (req: Request) => {
// get Authorization from header
const authorization = req.headers.get(LOBE_CHAT_AUTH_HEADER);
if (!authorization) throw AgentRuntimeError.createError(ChatErrorType.Unauthorized);
// TODO: need to be replace by better telemetry system
// add trace
const tracePayload = getTracePayload(req);
const traceClient = new TraceClient();
const trace = traceClient.createTrace({
id: tracePayload?.traceId,
...tracePayload,
});
const { manifest, indexUrl, ...input } = (await req.clone().json()) as PluginRequestPayload;
const span = trace?.span({
input,
metadata: { indexUrl, manifest },
name: TraceNameMap.FetchPluginAPI,
});
span?.update({ parentObservationId: tracePayload?.observationId });
const res = await handler(req);
span?.end({ output: await res.clone().text() });
if (trace?.id) {
res.headers.set(LOBE_CHAT_TRACE_ID, trace.id);
}
return res;
};

View file

@ -1,38 +1,19 @@
import { Flexbox } from '@lobehub/ui';
import { Switch } from 'antd';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { useToolStore } from '@/store/tool';
import { useStore } from '../store';
const MarketList = memo<{ id: string }>(({ id }) => {
const [toggleAgentPlugin, hasPlugin] = useStore((s) => [s.toggleAgentPlugin, !!s.config.plugins]);
const plugins = useStore((s) => s.config.plugins || []);
const [useFetchPluginList, fetchPluginManifest] = useToolStore((s) => [
s.useFetchPluginStore,
s.installPlugin,
]);
const pluginManifestLoading = useToolStore((s) => s.pluginInstallLoading, isEqual);
useFetchPluginList();
return (
<Flexbox horizontal align={'center'} gap={8}>
<Switch
loading={pluginManifestLoading[id]}
checked={
// If loading, it means it's activated
pluginManifestLoading[id] || !hasPlugin ? false : plugins.includes(id)
}
onChange={(checked) => {
checked={!hasPlugin ? false : plugins.includes(id)}
onChange={() => {
toggleAgentPlugin(id);
if (checked) {
fetchPluginManifest(id);
}
}}
/>
</Flexbox>

View file

@ -59,20 +59,17 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
const userAgentSkills = useToolStore(agentSkillsSelectors.getUserAgentSkills, isEqual);
const [
useFetchPluginStore,
useFetchUserKlavisServers,
useFetchLobehubSkillConnections,
useFetchUninstalledBuiltinTools,
useFetchAgentSkills,
] = useToolStore((s) => [
s.useFetchPluginStore,
s.useFetchUserKlavisServers,
s.useFetchLobehubSkillConnections,
s.useFetchUninstalledBuiltinTools,
s.useFetchAgentSkills,
]);
useFetchPluginStore();
useFetchInstalledPlugins();
useFetchUninstalledBuiltinTools(true);
useFetchAgentSkills(true);

View file

@ -1,9 +1,8 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { type ILobeAgentRuntimeErrorType } from '@lobechat/model-runtime';
import { AgentRuntimeErrorType } from '@lobechat/model-runtime';
import { type ChatMessageError, type ErrorType } from '@lobechat/types';
import { type ChatMessageError, type ErrorType, type IToolErrorType } from '@lobechat/types';
import { ChatErrorType } from '@lobechat/types';
import { type IPluginErrorType } from '@lobehub/chat-plugin-sdk';
import { type AlertProps } from '@lobehub/ui';
import { Block, Highlighter, Skeleton } from '@lobehub/ui';
import { memo, useMemo } from 'react';
@ -52,7 +51,7 @@ const OllamaSetupGuide = dynamic(() => import('./OllamaSetupGuide'), {
// Config for the errorMessage display
const getErrorAlertConfig = (
errorType?: IPluginErrorType | ILobeAgentRuntimeErrorType | ErrorType,
errorType?: IToolErrorType | ILobeAgentRuntimeErrorType | ErrorType,
): AlertProps | undefined => {
// OpenAIBizError / ZhipuBizError / GoogleBizError / ...
if (typeof errorType === 'string' && (errorType.includes('Biz') || errorType.includes('Invalid')))

View file

@ -1,9 +1,9 @@
import { getBuiltinRender } from '@lobechat/builtin-tools/renders';
import { type ChatPluginPayload } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import PluginRender from '@/features/PluginsUI/Render';
import { type ChatPluginPayload } from '@/types/index';
interface CustomRenderProps {
content: string;
/**
@ -19,19 +19,21 @@ interface CustomRenderProps {
}
const CustomRender = memo<CustomRenderProps>(
({ toolCallId, messageId, content, pluginState, plugin }) => {
({ content, messageId, plugin, pluginState, toolCallId }) => {
const Render = getBuiltinRender(plugin?.identifier, plugin?.apiName);
if (!Render) return null;
return (
<Flexbox gap={12} id={toolCallId} width={'100%'}>
<PluginRender
arguments={plugin?.arguments}
<Render
apiName={plugin?.apiName}
args={safeParseJSON(plugin?.arguments)}
content={content}
identifier={plugin?.identifier}
loading={false}
messageId={messageId}
payload={plugin}
messageId={messageId!}
pluginState={pluginState}
toolCallId={toolCallId}
type={plugin?.type}
/>
</Flexbox>
);

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { safeParseJSON } from '@/utils/safeParseJSON';
@ -14,7 +14,7 @@ interface McpServers {
}
interface ParsedMcpInput {
manifest?: LobeChatPluginManifest;
manifest?: ToolManifest;
mcpServers?: McpServers;
}

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { Block, Button, Flexbox, Icon, Text } from '@lobehub/ui';
import { type FormInstance } from 'antd';
import { Form as AForm } from 'antd';
@ -17,7 +17,7 @@ import PluginEmptyState from './EmptyState';
const PluginPreview = memo<{ form: FormInstance }>(({ form }) => {
const { t } = useTranslation('plugin');
const manifest: LobeChatPluginManifest = AForm.useWatch(['manifest'], form);
const manifest: ToolManifest = AForm.useWatch(['manifest'], form);
const meta = manifest?.meta;
if (!manifest)

View file

@ -1,5 +1,5 @@
import { BRANDING_NAME } from '@lobechat/business-const';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { ActionIcon, Checkbox, Flexbox, FormItem, Input } from '@lobehub/ui';
import { type FormInstance } from 'antd';
import { Form } from 'antd';
@ -39,7 +39,7 @@ const UrlManifestForm = memo<{ form: FormInstance; isEditMode: boolean }>(
({ form, isEditMode }) => {
const { t } = useTranslation('plugin');
const [manifest, setManifest] = useState<LobeChatPluginManifest>();
const [manifest, setManifest] = useState<ToolManifest>();
const urlKey = ['customParams', 'manifestUrl'];
const proxyKey = ['customParams', 'useProxy'];

View file

@ -1,4 +1,4 @@
import { type PluginSchema } from '@lobehub/chat-plugin-sdk';
import { type ToolManifestSettings } from '@lobechat/types';
import { Form, Markdown } from '@lobehub/ui';
import { Form as AForm } from 'antd';
import { createStaticStyles } from 'antd-style';
@ -10,7 +10,7 @@ import { pluginSelectors } from '@/store/tool/selectors';
import ItemRender from '../../components/JSONSchemaConfig/ItemRender';
export const transformPluginSettings = (pluginSettings: PluginSchema) => {
export const transformPluginSettings = (pluginSettings: ToolManifestSettings) => {
if (!pluginSettings?.properties) return [];
return Object.entries(pluginSettings.properties).map(([name, i]) => ({
@ -28,7 +28,7 @@ export const transformPluginSettings = (pluginSettings: PluginSchema) => {
interface PluginSettingsConfigProps {
id: string;
schema: PluginSchema;
schema: ToolManifestSettings;
}
const styles = createStaticStyles(({ css, cssVar }) => ({

View file

@ -1,69 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import BuiltinType from './index';
// Mock renders module
const mockWebBrowsingRender = vi.fn(({ content }) => <div>WebBrowsingRender: {content}</div>);
const mockCodeInterpreterRender = vi.fn(({ content }) => (
<div>CodeInterpreterRender: {content}</div>
));
vi.mock('@lobechat/builtin-tools/renders', () => ({
getBuiltinRender: vi.fn((identifier, apiName) => {
if (identifier === 'lobe-web-browsing') return mockWebBrowsingRender;
if (identifier === 'lobe-code-interpreter') return mockCodeInterpreterRender;
return undefined;
}),
}));
// Mock useParseContent hook
vi.mock('../useParseContent', () => ({
useParseContent: vi.fn((content) => ({ data: content })),
}));
describe('BuiltinType', () => {
it('should not render anything if identifier is not provided', () => {
const { container } = render(<BuiltinType content="..." messageId="123" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything if identifier is unknown', () => {
const { container } = render(<BuiltinType content="{}" identifier="unknown" messageId="123" />);
expect(container).toBeEmptyDOMElement();
});
it('should render the correct renderer for web browsing', () => {
const content = '{"query":"test"}';
render(<BuiltinType content={content} identifier="lobe-web-browsing" messageId="123" />);
expect(screen.getByText(`WebBrowsingRender: ${content}`)).toBeInTheDocument();
});
it('should render the correct renderer for code interpreter', () => {
const content = '{"code":"print(1)"}';
render(<BuiltinType content={content} identifier="lobe-code-interpreter" messageId="123" />);
expect(screen.getByText(`CodeInterpreterRender: ${content}`)).toBeInTheDocument();
});
it('should pass correct props to renderer', () => {
const content = '{"test":"data"}';
const args = '{"arg":"value"}';
const pluginState = { state: 'value' };
const pluginError = { error: 'test' };
render(
<BuiltinType
apiName="testApi"
arguments={args}
content={content}
identifier="lobe-web-browsing"
messageId="msg-123"
pluginError={pluginError}
pluginState={pluginState}
toolCallId="tool-call-123"
/>,
);
expect(screen.getByText(`WebBrowsingRender: ${content}`)).toBeInTheDocument();
});
});

View file

@ -1,64 +0,0 @@
import { getBuiltinRender } from '@lobechat/builtin-tools/renders';
import { ToolRenderProvider } from '@lobechat/shared-tool-ui';
import { safeParseJSON } from '@lobechat/utils';
import { memo } from 'react';
import { useParseContent } from '../useParseContent';
import { useToolRenderCaps } from './useToolRenderCaps';
export interface BuiltinTypeProps {
apiName?: string;
arguments?: string;
content: string;
identifier?: string;
loading?: boolean;
/**
* The real message ID (tool message ID)
*/
messageId?: string;
pluginError?: any;
pluginState?: any;
/**
* The tool call ID from the assistant message
*/
toolCallId?: string;
}
const BuiltinType = memo<BuiltinTypeProps>(
({
content,
arguments: argumentsStr = '',
pluginState,
toolCallId,
messageId,
identifier,
pluginError,
apiName,
}) => {
const { data } = useParseContent(content);
const caps = useToolRenderCaps();
const Render = getBuiltinRender(identifier, apiName);
if (!Render) return;
const args = safeParseJSON(argumentsStr);
return (
<ToolRenderProvider value={caps}>
<Render
apiName={apiName}
args={args || {}}
content={data}
identifier={identifier}
messageId={messageId || toolCallId || ''}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
</ToolRenderProvider>
);
},
);
export default BuiltinType;

View file

@ -1,61 +0,0 @@
import { type PluginRenderProps } from '@lobehub/chat-plugin-sdk/client';
import { Skeleton } from '@lobehub/ui';
import { memo, useRef, useState } from 'react';
import { useOnPluginReadyForInteraction } from '../../utils/iframeOnReady';
import { useOnPluginFetchMessage } from '../../utils/listenToPlugin';
import { sendMessageContentToPlugin } from '../../utils/postMessage';
interface IFrameRenderProps extends PluginRenderProps {
height?: number;
url: string;
width?: number;
}
const IFrameRender = memo<IFrameRenderProps>(({ url, width = 800, height = 300, ...props }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
// When props change, proactively send data to the iframe
useOnPluginReadyForInteraction(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
sendMessageContentToPlugin(iframeWin, props);
}
}, [props]);
// when get iframe fetch message send message content
useOnPluginFetchMessage(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
sendMessageContentToPlugin(iframeWin, props);
}
}, [props]);
return (
<>
{loading && <Skeleton active style={{ maxWidth: '100%', width }} />}
<iframe
// @ts-ignore
allowtransparency="true"
height={height}
hidden={loading}
ref={iframeRef}
src={url}
width={width}
style={{
border: 0,
// iframe cannot be transparent in color-scheme:dark mode
// refs: https://www.jianshu.com/p/bc5a37bb6a7b
colorScheme: 'light',
maxWidth: '100%',
}}
onLoad={() => {
setLoading(false);
}}
/>
</>
);
});
export default IFrameRender;

View file

@ -1,42 +0,0 @@
import { memo } from 'react';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import Loading from '../Loading';
import { useParseContent } from '../useParseContent';
import IFrameRender from './IFrameRender';
export interface PluginDefaultTypeProps {
content: string;
loading?: boolean;
name?: string;
}
const PluginDefaultType = memo<PluginDefaultTypeProps>(({ content, name, loading }) => {
const manifest = useToolStore(pluginSelectors.getToolManifestById(name || ''));
const { isJSON, data } = useParseContent(content);
if (!isJSON) {
return loading && <Loading />;
}
if (!manifest?.ui) return;
const ui = manifest.ui;
if (!ui.url) return;
return (
<IFrameRender
content={data}
height={ui.height}
name={name || 'unknown'}
url={ui.url}
width={ui.width}
/>
);
});
export default PluginDefaultType;

View file

@ -1,58 +0,0 @@
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles, keyframes } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const animloader = keyframes`
0% {
inset-inline-start: 0;
transform: translateX(-100%);
}
100% {
inset-inline-start: 100%;
transform: translateX(0%);
}
`;
const styles = createStaticStyles(
({ css, cssVar }) => css`
position: relative;
overflow: hidden;
display: block;
width: 300px;
height: 12px;
border: 1px solid ${cssVar.colorBorder};
border-radius: 10px;
&::after {
content: '';
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
box-sizing: border-box;
width: 40%;
height: 100%;
background: ${cssVar.colorPrimary};
animation: ${animloader} 2s linear infinite;
}
`,
);
const Loading = memo(() => {
const { t } = useTranslation('plugin');
return (
<Flexbox align={'center'} gap={8} padding={16}>
<span className={styles} />
{t('loading.content')}
</Flexbox>
);
});
export default Loading;

View file

@ -1,74 +0,0 @@
import { Flexbox, Image, Markdown } from '@lobehub/ui';
import { memo } from 'react';
import Arguments from '@/features/Conversation/Messages/AssistantGroup/Tool/Detail/Arguments';
import { type ToolCallResult } from '@/libs/mcp';
export interface MCPTypeProps {
apiName?: string;
arguments?: string;
content: string;
identifier?: string;
loading?: boolean;
/**
* The real message ID (tool message ID)
*/
messageId?: string;
pluginError?: any;
pluginState?: ToolCallResult;
/**
* The tool call ID from the assistant message
*/
toolCallId?: string;
}
const MCPType = memo<MCPTypeProps>(({ pluginState, arguments: args }) => {
if (!pluginState) return;
const { content } = pluginState;
const hasImage = content.some((item) => item.type === 'image');
return (
<Flexbox
gap={8}
style={
!hasImage ? { maxHeight: 400, overflow: 'scroll', padding: 8, width: '100%' } : undefined
}
>
{args && <Arguments arguments={args} />}
<Flexbox>
<Flexbox>
{content.map((item, index) => {
switch (item.type) {
case 'text': {
return (
<Markdown key={item.text} variant={'chat'}>
{item.text}
</Markdown>
);
}
case 'image': {
return (
<Image
alt="MCP content"
height={'auto'}
key={`image-${index}`}
src={item.data}
width={'100%'}
/>
);
}
default: {
return null;
}
}
})}
</Flexbox>
</Flexbox>
</Flexbox>
);
});
export default MCPType;

View file

@ -1,25 +0,0 @@
import { Markdown } from '@lobehub/ui';
import { memo } from 'react';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import Loading from '../Loading';
export interface PluginMarkdownTypeProps {
content: string;
loading?: boolean;
}
const PluginMarkdownType = memo<PluginMarkdownTypeProps>(({ content, loading }) => {
const fontSize = useUserStore(userGeneralSettingsSelectors.fontSize);
if (loading) return <Loading />;
return (
<Markdown fontSize={fontSize} variant={'chat'}>
{content}
</Markdown>
);
});
export default PluginMarkdownType;

View file

@ -1,163 +0,0 @@
import { type PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { Skeleton } from '@lobehub/ui';
import { memo, useRef, useState } from 'react';
import { useChatStore } from '@/store/chat';
import { dbMessageSelectors } from '@/store/chat/selectors';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { useOnPluginReadyForInteraction } from '../utils/iframeOnReady';
import {
useOnPluginCreateAssistantMessage,
useOnPluginFetchMessage,
useOnPluginFetchPluginSettings,
useOnPluginFetchPluginState,
useOnPluginFillContent,
useOnPluginTriggerAIMessage,
} from '../utils/listenToPlugin';
import { useOnPluginSettingsUpdate } from '../utils/pluginSettings';
import { useOnPluginStateUpdate } from '../utils/pluginState';
import {
sendMessageContentToPlugin,
sendPayloadToPlugin,
sendPluginSettingsToPlugin,
sendPluginStateToPlugin,
} from '../utils/postMessage';
// just to simplify code a little, don't use this pattern everywhere
const getSettings = (identifier: string) =>
pluginSelectors.getPluginSettingsById(identifier)(useToolStore.getState());
const getMessage = (id: string) => dbMessageSelectors.getDbMessageById(id)(useChatStore.getState());
interface IFrameRenderProps {
height?: number;
id: string;
payload?: PluginRequestPayload;
url: string;
width?: number;
}
const IFrameRender = memo<IFrameRenderProps>(({ url, id, payload, width = 600, height = 300 }) => {
const [loading, setLoading] = useState(true);
const iframeRef = useRef<HTMLIFrameElement>(null);
// when payload changesend content to plugin
useOnPluginReadyForInteraction(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin && payload) {
const settings = getSettings(payload.identifier);
const message = getMessage(id);
const state = message?.pluginState;
sendPayloadToPlugin(iframeWin, { payload, settings, state });
}
}, [payload]);
// when plugin wants to get message content, send it to plugin
useOnPluginFetchMessage(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
const message = dbMessageSelectors.getDbMessageById(id)(useChatStore.getState());
if (!message) return;
const props = { content: '' };
try {
props.content = JSON.parse(message.content || '{}');
} catch {
props.content = message.content || '';
}
sendMessageContentToPlugin(iframeWin, props);
}
}, []);
// when plugin try to send back message, we should fill it to the message content
const fillPluginContent = useChatStore((s) => s.fillPluginMessageContent);
useOnPluginFillContent((content, triggerAiMessage) => {
fillPluginContent(id, content, triggerAiMessage);
});
// when plugin wants to get plugin state, send it to plugin
useOnPluginFetchPluginState((key) => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
const message = getMessage(id);
if (!message) return;
sendPluginStateToPlugin(iframeWin, key, message.pluginState?.[key]);
}
});
// when plugin update state, we should update it to the message pluginState key
const optimisticUpdatePluginState = useChatStore((s) => s.optimisticUpdatePluginState);
useOnPluginStateUpdate((key, value) => {
optimisticUpdatePluginState(id, { [key]: value });
});
// when plugin wants to get plugin settings, send it to plugin
useOnPluginFetchPluginSettings(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
if (!payload?.identifier) return;
const settings = getSettings(payload.identifier);
sendPluginSettingsToPlugin(iframeWin, settings);
}
});
// when plugin update settings, we should update it to the plugin settings
const updatePluginSettings = useToolStore((s) => s.updatePluginSettings);
useOnPluginSettingsUpdate((value) => {
if (!payload?.identifier) return;
updatePluginSettings(payload?.identifier, value);
});
// when plugin want to trigger AI message
const triggerAIMessage = useChatStore((s) => s.triggerAIMessage);
useOnPluginTriggerAIMessage((messageId) => {
// we need to know which message to trigger
if (messageId !== id) return;
triggerAIMessage({ parentId: id });
});
// when plugin want to create an assistant message
const createAssistantMessage = useChatStore((s) => s.createAssistantMessageByPlugin);
useOnPluginCreateAssistantMessage((content) => {
createAssistantMessage(content, id);
});
return (
<>
{loading && <Skeleton active style={{ maxWidth: '100%', width }} />}
<iframe
// @ts-ignore
allowtransparency="true"
height={height}
hidden={loading}
ref={iframeRef}
src={url}
width={width}
style={{
border: 0,
// iframe cannot be transparent in color-scheme:dark mode
// refs: https://www.jianshu.com/p/bc5a37bb6a7b
colorScheme: 'light',
maxWidth: '100%',
}}
onLoad={() => {
setLoading(false);
}}
/>
</>
);
});
export default IFrameRender;

View file

@ -1,37 +0,0 @@
import { type PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { memo } from 'react';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/slices/plugin/selectors';
import IFrameRender from './Iframe';
export interface PluginStandaloneTypeProps {
id: string;
name?: string;
payload?: PluginRequestPayload;
}
const PluginDefaultType = memo<PluginStandaloneTypeProps>(({ payload, id, name = 'unknown' }) => {
const manifest = useToolStore(pluginSelectors.getToolManifestById(name));
if (!manifest?.ui) return;
const ui = manifest.ui;
if (!ui.url) return;
// if the id start with "tmp", return directly to avoid duplicate rendering
if (id.startsWith('tmp')) return;
return (
<IFrameRender
height={ui.height}
id={id}
key={id}
payload={payload}
url={ui.url}
width={ui.width}
/>
);
});
export default PluginDefaultType;

View file

@ -1,115 +0,0 @@
import { type LobeToolRenderType } from '@lobechat/types';
import { type PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { memo } from 'react';
import SafeBoundary from '@/components/ErrorBoundary';
import BuiltinType from './BuiltinType';
import DefaultType from './DefaultType';
import Markdown from './MarkdownType';
import MCP from './MCPType';
import Standalone from './StandaloneType';
export interface PluginRenderProps {
arguments?: string;
content: string;
identifier?: string;
loading?: boolean;
/**
* The real message ID (tool message ID)
*/
messageId?: string;
payload?: PluginRequestPayload;
pluginError?: any;
pluginState?: any;
/**
* The tool call ID from the assistant message
*/
toolCallId?: string;
type?: LobeToolRenderType;
}
const PluginRender = memo<PluginRenderProps>(
({
content,
arguments: argumentsStr = '',
toolCallId,
messageId,
payload,
pluginState,
identifier,
type,
loading,
pluginError,
}) => {
const renderContent = () => {
switch (type) {
case 'standalone': {
return (
<Standalone id={toolCallId || messageId || ''} name={identifier} payload={payload} />
);
}
case 'builtin': {
return (
<BuiltinType
apiName={payload?.apiName}
arguments={argumentsStr}
content={content}
identifier={identifier}
loading={loading}
messageId={messageId}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
);
}
// @ts-expect-error need to update types
case 'mcp': {
return (
<MCP
apiName={payload?.apiName}
arguments={argumentsStr}
content={content}
identifier={identifier}
loading={loading}
messageId={messageId}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
);
}
case 'markdown': {
return <Markdown content={content} loading={loading} />;
}
default: {
return <DefaultType content={content} loading={loading} name={identifier} />;
}
}
};
// Use stable key to prevent ErrorBoundary from resetting on parent re-renders
const boundaryKey = `${identifier}-${payload?.apiName}-${toolCallId || messageId}`;
return (
<SafeBoundary
key={boundaryKey}
variant="alert"
alertTitle={
identifier
? `${identifier}${payload?.apiName ? ` / ${payload.apiName}` : ''}`
: 'Tool Render Error'
}
>
{renderContent()}
</SafeBoundary>
);
},
);
export default PluginRender;

View file

@ -1,15 +0,0 @@
import { useMemo } from 'react';
export const useParseContent = (content: string) => {
let isJSON = true;
try {
JSON.parse(content);
} catch {
isJSON = false;
}
const data = isJSON ? JSON.parse(content) : content;
return useMemo(() => ({ data, isJSON }), [content]);
};

View file

@ -1,76 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useOnPluginReadyForInteraction } from './iframeOnReady';
describe('useOnPluginReadyForInteraction', () => {
const mockOnReady = vi.fn();
afterEach(() => {
mockOnReady.mockReset();
// eslint-disable-next-line unicorn/no-invalid-remove-event-listener
window.removeEventListener('message', () => {});
});
it('sets readyForRender to true when a PluginChannel.pluginReadyForRender message is received', async () => {
const { result } = renderHook(() => useOnPluginReadyForInteraction(mockOnReady));
expect(result.current).toBe(false); // Initially, readyForRender should be false
const event = new MessageEvent('message', {
data: { type: PluginChannel.pluginReadyForRender },
});
act(() => {
window.dispatchEvent(event);
});
expect(result.current).toBe(true); // After the event, readyForRender should be true
});
it('sets readyForRender to true when a PluginChannel.pluginReadyForRender message is received', async () => {
const { result } = renderHook(() => useOnPluginReadyForInteraction(mockOnReady));
expect(result.current).toBe(false); // Initially, readyForRender should be false
const event = new MessageEvent('message', {
data: { type: PluginChannel.pluginReadyForRender },
});
act(() => {
window.dispatchEvent(event);
});
expect(result.current).toBe(true); // After the event, readyForRender should be true
expect(mockOnReady).toHaveBeenCalledTimes(1); // onReady should have been called once
});
it('does not call onReady for non-pluginReadyForRender messages', async () => {
renderHook(() => useOnPluginReadyForInteraction(mockOnReady));
const event = new MessageEvent('message', {
data: { type: 'nonPluginReadyMessage' },
});
act(() => {
window.dispatchEvent(event);
});
expect(mockOnReady).not.toHaveBeenCalled();
});
it('cleans up message event listener on unmount', () => {
const { unmount } = renderHook(() => useOnPluginReadyForInteraction(mockOnReady));
unmount();
const event = new MessageEvent('message', {
data: { type: PluginChannel.pluginReadyForRender },
});
window.dispatchEvent(event);
expect(mockOnReady).not.toHaveBeenCalled();
});
});

View file

@ -1,25 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect, useState } from 'react';
export const useOnPluginReadyForInteraction = (onReady: () => void, deps: any[] = []) => {
const [readyForRender, setReady] = useState(false);
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.pluginReadyForRender) {
setReady(true);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
useEffect(() => {
if (readyForRender) {
onReady();
}
}, [readyForRender, ...deps]);
return readyForRender;
};

View file

@ -1,164 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
useOnPluginCreateAssistantMessage,
useOnPluginFetchMessage,
useOnPluginFetchPluginSettings,
useOnPluginFetchPluginState,
useOnPluginFillContent,
useOnPluginTriggerAIMessage,
} from './listenToPlugin';
afterEach(() => {
vi.restoreAllMocks();
});
describe('useOnPluginFetchMessage', () => {
it('calls onRequest when a fetchPluginMessage is received', () => {
const mockOnRequest = vi.fn();
renderHook(() => useOnPluginFetchMessage(mockOnRequest));
const testData = { key: 'testData', type: PluginChannel.fetchPluginMessage };
const event = new MessageEvent('message', {
data: testData,
});
window.dispatchEvent(event);
expect(mockOnRequest).toHaveBeenCalledWith(testData);
});
it('does not call onRequest for other message types', () => {
const mockOnRequest = vi.fn();
renderHook(() => useOnPluginFetchMessage(mockOnRequest));
const event = new MessageEvent('message', {
data: { type: 'otherMessageType' },
});
window.dispatchEvent(event);
expect(mockOnRequest).not.toHaveBeenCalled();
});
});
describe('useOnPluginFetchPluginState', () => {
it('calls onRequest with the key when a fetchPluginState message is received', () => {
const mockOnRequest = vi.fn();
renderHook(() => useOnPluginFetchPluginState(mockOnRequest));
const testKey = 'testKey';
const event = new MessageEvent('message', {
data: { type: PluginChannel.fetchPluginState, key: testKey },
});
window.dispatchEvent(event);
expect(mockOnRequest).toHaveBeenCalledWith(testKey);
});
});
describe('useOnPluginFillContent', () => {
it('calls callback with content when a fillStandalonePluginContent message is received', () => {
const mockCallback = vi.fn();
renderHook(() => useOnPluginFillContent(mockCallback));
const testContent = 'testContent';
const event = new MessageEvent('message', {
data: { type: PluginChannel.fillStandalonePluginContent, content: testContent },
});
window.dispatchEvent(event);
expect(mockCallback).toHaveBeenCalledWith(testContent, undefined);
});
it('calls callback with JSON stringified content if content is not a string', () => {
const mockCallback = vi.fn();
renderHook(() => useOnPluginFillContent(mockCallback));
const testContent = { some: 'data' };
const event = new MessageEvent('message', {
data: { type: PluginChannel.fillStandalonePluginContent, content: testContent },
});
window.dispatchEvent(event);
expect(mockCallback).toHaveBeenCalledWith(JSON.stringify(testContent), undefined);
});
});
describe('useOnPluginFetchPluginSettings', () => {
it('calls onRequest when a fetchPluginSettings message is received', () => {
const mockOnRequest = vi.fn();
renderHook(() => useOnPluginFetchPluginSettings(mockOnRequest));
const event = new MessageEvent('message', {
data: { type: PluginChannel.fetchPluginSettings },
});
window.dispatchEvent(event);
expect(mockOnRequest).toHaveBeenCalled();
});
});
describe('useOnPluginTriggerAIMessage', () => {
it('calls callback with id when a triggerAIMessage is received', () => {
const mockCallback = vi.fn();
renderHook(() => useOnPluginTriggerAIMessage(mockCallback));
const testId = 'testId';
const event = new MessageEvent('message', {
data: { type: PluginChannel.triggerAIMessage, id: testId },
});
window.dispatchEvent(event);
expect(mockCallback).toHaveBeenCalledWith(testId);
});
it('does not call callback for other message types', () => {
const mockCallback = vi.fn();
renderHook(() => useOnPluginTriggerAIMessage(mockCallback));
const event = new MessageEvent('message', {
data: { type: 'otherMessageType', id: 'testId' },
});
window.dispatchEvent(event);
expect(mockCallback).not.toHaveBeenCalled();
});
});
describe('useOnPluginCreateAssistantMessage', () => {
it('calls callback with content when a createAssistantMessage is received', () => {
const mockCallback = vi.fn();
renderHook(() => useOnPluginCreateAssistantMessage(mockCallback));
const testContent = 'testContent';
const event = new MessageEvent('message', {
data: { type: PluginChannel.createAssistantMessage, content: testContent },
});
window.dispatchEvent(event);
expect(mockCallback).toHaveBeenCalledWith(testContent);
});
it('does not call callback for other message types', () => {
const mockCallback = vi.fn();
renderHook(() => useOnPluginCreateAssistantMessage(mockCallback));
const event = new MessageEvent('message', {
data: { type: 'otherMessageType', content: 'testContent' },
});
window.dispatchEvent(event);
expect(mockCallback).not.toHaveBeenCalled();
});
});

View file

@ -1,98 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect } from 'react';
export const useOnPluginFetchMessage = (onRequest: (data: any) => void, deps: any[] = []) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fetchPluginMessage) {
onRequest(e.data);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, deps);
};
export const useOnPluginFetchPluginState = (onRequest: (key: string) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fetchPluginState) {
onRequest(e.data.key);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginFillContent = (
callback: (content: string, triggerAiMessage?: boolean) => void,
) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fillStandalonePluginContent) {
const data = e.data.content;
const triggerAiMessage = e.data.triggerAiMessage;
const content = typeof data !== 'string' ? JSON.stringify(data) : data;
callback(content, triggerAiMessage);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginFetchPluginSettings = (onRequest: () => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fetchPluginSettings) {
onRequest();
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginTriggerAIMessage = (callback: (id: string) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.triggerAIMessage) {
callback(e.data.id);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginCreateAssistantMessage = (callback: (content: string) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.createAssistantMessage) {
callback(e.data.content);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};

View file

@ -1,54 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useOnPluginSettingsUpdate } from './pluginSettings';
describe('useOnPluginSettingsUpdate', () => {
const mockCallback = vi.fn();
afterEach(() => {
mockCallback.mockReset();
// eslint-disable-next-line unicorn/no-invalid-remove-event-listener
window.removeEventListener('message', () => {});
});
it('calls the callback when a PluginChannel updatePluginSettings message is received', () => {
renderHook(() => useOnPluginSettingsUpdate(mockCallback));
const testSettings = { theme: 'dark', notifications: true };
const event = new MessageEvent('message', {
data: { type: PluginChannel.updatePluginSettings, value: testSettings },
});
window.dispatchEvent(event);
expect(mockCallback).toHaveBeenCalledWith(testSettings);
});
it('does not call the callback for non-updatePluginSettings messages', () => {
renderHook(() => useOnPluginSettingsUpdate(mockCallback));
const event = new MessageEvent('message', {
data: { type: 'nonPluginSettingsUpdate', value: { irrelevant: true } },
});
window.dispatchEvent(event);
expect(mockCallback).not.toHaveBeenCalled();
});
it('cleans up message event listener on unmount', () => {
const { unmount } = renderHook(() => useOnPluginSettingsUpdate(mockCallback));
unmount();
const event = new MessageEvent('message', {
data: { type: PluginChannel.updatePluginSettings, value: {} },
});
window.dispatchEvent(event);
expect(mockCallback).not.toHaveBeenCalled();
});
});

View file

@ -1,17 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect } from 'react';
export const useOnPluginSettingsUpdate = (callback: (settings: any) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.updatePluginSettings) {
callback(e.data.value);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};

View file

@ -1,44 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useOnPluginStateUpdate } from './pluginState';
describe('useOnPluginStateUpdate', () => {
// Mock for the callback function to be used in tests
const mockCallback = vi.fn();
afterEach(() => {
// Reset the mock callback after each test
mockCallback.mockReset();
// Ensure no event listeners are left hanging after each test
// eslint-disable-next-line unicorn/no-invalid-remove-event-listener
window.removeEventListener('message', () => {});
});
it('calls the callback when a PluginChannel update message is received', () => {
renderHook(() => useOnPluginStateUpdate(mockCallback));
const testKey = 'testKey';
const testValue = 'testValue';
const event = new MessageEvent('message', {
data: { type: PluginChannel.updatePluginState, key: testKey, value: testValue },
});
window.dispatchEvent(event);
expect(mockCallback).toHaveBeenCalledWith(testKey, testValue);
});
it('does not call the callback for non-PluginChannel messages', () => {
renderHook(() => useOnPluginStateUpdate(mockCallback));
const event = new MessageEvent('message', {
data: { type: 'nonPluginMessage', key: 'key', value: 'value' },
});
window.dispatchEvent(event);
expect(mockCallback).not.toHaveBeenCalled();
});
});

View file

@ -1,20 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect } from 'react';
export const useOnPluginStateUpdate = (callback: (key: string, value: any) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.updatePluginState) {
const key = e.data.key;
const value = e.data.value;
callback(key, value);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};

View file

@ -1,64 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { describe, expect, it, vi } from 'vitest';
import {
sendMessageContentToPlugin,
sendPayloadToPlugin,
sendPluginSettingsToPlugin,
sendPluginStateToPlugin,
} from './postMessage';
// Mock window object with a postMessage spy
const mockWindow = {
postMessage: vi.fn(),
};
describe('plugin communication functions', () => {
it('sendMessageContentToPlugin should call window.postMessage with correct arguments', () => {
const props = { some: 'data' };
sendMessageContentToPlugin(mockWindow as unknown as Window, props);
expect(mockWindow.postMessage).toHaveBeenCalledWith(
{ props, type: PluginChannel.renderPlugin },
'*',
);
});
it('sendPayloadToPlugin should call window.postMessage with correct arguments', () => {
const props = { payload: 'payload', settings: 'settings', state: 'state' };
sendPayloadToPlugin(mockWindow as unknown as Window, props);
expect(mockWindow.postMessage).toHaveBeenCalledWith(
{
type: PluginChannel.initStandalonePlugin,
payload: props.payload,
settings: props.settings,
state: props.state,
props: props.payload, // Note: This is due to the TODO in your code
},
'*',
);
});
it('sendPluginStateToPlugin should call window.postMessage with correct arguments', () => {
const key = 'key';
const value = 'value';
sendPluginStateToPlugin(mockWindow as unknown as Window, key, value);
expect(mockWindow.postMessage).toHaveBeenCalledWith(
{ key, type: PluginChannel.renderPluginState, value },
'*',
);
});
it('sendPluginSettingsToPlugin should call window.postMessage with correct arguments', () => {
const settings = { setting1: 'value1' };
sendPluginSettingsToPlugin(mockWindow as unknown as Window, settings);
expect(mockWindow.postMessage).toHaveBeenCalledWith(
{ type: PluginChannel.renderPluginSettings, value: settings },
'*',
);
});
});
// Reset the mock after each test
afterEach(() => {
vi.resetAllMocks();
});

View file

@ -1,28 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
export const sendMessageContentToPlugin = (window: Window, props: any) => {
window.postMessage({ props, type: PluginChannel.renderPlugin }, '*');
};
export const sendPayloadToPlugin = (
window: Window,
props: { payload: any; settings: any; state?: any },
) => {
window.postMessage(
{
type: PluginChannel.initStandalonePlugin,
...props,
// TODO: props need to deprecated
props: props.payload,
},
'*',
);
};
export const sendPluginStateToPlugin = (window: Window, key: string, value: any) => {
window.postMessage({ key, type: PluginChannel.renderPluginState, value }, '*');
};
export const sendPluginSettingsToPlugin = (window: Window, settings: any) => {
window.postMessage({ type: PluginChannel.renderPluginSettings, value: settings }, '*');
};

View file

@ -2,7 +2,6 @@ import { BuiltinToolsPortals } from '@lobechat/builtin-tools/portals';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import PluginRender from '@/features/PluginsUI/Render';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors, dbMessageSelectors } from '@/store/chat/selectors';
import { safeParseJSON } from '@/utils/safeParseJSON';
@ -25,19 +24,7 @@ const ToolRender = memo(() => {
const Render = BuiltinToolsPortals[plugin.identifier];
if (!Render)
return (
<PluginRender
arguments={plugin.arguments}
content={message.content}
identifier={plugin.identifier}
messageId={messageId}
payload={plugin}
pluginState={pluginState}
toolCallId={message.tool_call_id}
type={plugin?.type}
/>
);
if (!Render) return null;
return (
<Render

View file

@ -116,19 +116,16 @@ const AgentTool = memo<AgentToolProps>(
// Fetch plugins
const [
useFetchPluginStore,
useFetchUserKlavisServers,
useFetchLobehubSkillConnections,
useFetchUninstalledBuiltinTools,
useFetchAgentSkills,
] = useToolStore((s) => [
s.useFetchPluginStore,
s.useFetchUserKlavisServers,
s.useFetchLobehubSkillConnections,
s.useFetchUninstalledBuiltinTools,
s.useFetchAgentSkills,
]);
useFetchPluginStore();
useFetchInstalledPlugins();
useFetchUninstalledBuiltinTools(true);
useFetchAgentSkills(true);

View file

@ -41,7 +41,7 @@ const Item = memo<DiscoverMcpItem>(({ name, description, icon, identifier }) =>
mcpStoreSelectors.isMCPInstalling(identifier)(s),
s.installMCPPlugin,
s.cancelInstallMCPPlugin,
s.uninstallPlugin,
s.uninstallMCPPlugin,
mcpStoreSelectors.getPluginById(identifier)(s),
]);

View file

@ -51,7 +51,7 @@ const Item = memo<ItemProps>(({ identifier, title, description, avatar }) => {
const [customPlugin, uninstallPlugin, updateCustomPlugin, pluginManifest] = useToolStore((s) => [
pluginSelectors.getCustomPluginById(identifier)(s),
s.uninstallPlugin,
s.uninstallCustomPlugin,
s.updateCustomPlugin,
pluginSelectors.getToolManifestById(identifier)(s),
]);

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createAgentToolsEngine, createToolsEngine, getEnabledTools } from './index';
@ -30,7 +30,7 @@ vi.mock('@/store/tool', () => ({
avatar: '🔍',
},
type: 'builtin',
} as unknown as LobeChatPluginManifest,
} as unknown as ToolManifest,
type: 'builtin' as const,
},
{
@ -56,7 +56,7 @@ vi.mock('@/store/tool', () => ({
avatar: '🌐',
},
type: 'builtin',
} as unknown as LobeChatPluginManifest,
} as unknown as ToolManifest,
type: 'builtin' as const,
},
],
@ -64,7 +64,7 @@ vi.mock('@/store/tool', () => ({
}));
let mockGetInstalledPluginById: (id: string) => () => any = () => () => undefined;
let mockInstalledPluginManifestList: () => LobeChatPluginManifest[] = () => [];
let mockInstalledPluginManifestList: () => ToolManifest[] = () => [];
vi.mock('@/store/tool/selectors', () => ({
pluginSelectors: {
@ -270,7 +270,7 @@ describe('toolEngineering', () => {
identifier: 'stdio-mcp-plugin',
meta: { title: 'Stdio MCP', avatar: '🔧' },
type: 'default',
} as unknown as LobeChatPluginManifest,
} as unknown as ToolManifest,
];
mockGetInstalledPluginById = (id: string) => () =>
id === 'stdio-mcp-plugin'
@ -305,7 +305,7 @@ describe('toolEngineering', () => {
identifier: 'stdio-mcp-plugin',
meta: { title: 'Stdio MCP', avatar: '🔧' },
type: 'default',
} as unknown as LobeChatPluginManifest;
} as unknown as ToolManifest;
const httpMcpManifest = {
api: [
@ -318,7 +318,7 @@ describe('toolEngineering', () => {
identifier: 'http-mcp-plugin',
meta: { title: 'HTTP MCP', avatar: '🌐' },
type: 'default',
} as unknown as LobeChatPluginManifest;
} as unknown as ToolManifest;
it('should filter stdio MCP tools in non-desktop environment', () => {
mockInstalledPluginManifestList = () => [stdioMcpManifest];

View file

@ -9,8 +9,7 @@ import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { alwaysOnToolIds, defaultToolIds } from '@lobechat/builtin-tools';
import { createEnableChecker, type PluginEnableChecker } from '@lobechat/context-engine';
import { ToolsEngine } from '@lobechat/context-engine';
import { type ChatCompletionTool, type WorkingModel } from '@lobechat/types';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ChatCompletionTool, type ToolManifest, type WorkingModel } from '@lobechat/types';
import { isToolAvailableInCurrentEnv } from '@/helpers/toolAvailability';
import { getAgentStoreState } from '@/store/agent';
@ -32,7 +31,7 @@ import { isCanUseFC } from '../isCanUseFC';
*/
export interface ToolsEngineConfig {
/** Additional manifests to include beyond the standard ones */
additionalManifests?: LobeChatPluginManifest[];
additionalManifests?: ToolManifest[];
/** Default tool IDs that will always be added to the end of the tools list */
defaultToolIds?: string[];
/** Custom enable checker for plugins */
@ -51,20 +50,16 @@ export const createToolsEngine = (config: ToolsEngineConfig = {}): ToolsEngine =
const pluginManifests = pluginSelectors.installedPluginManifestList(toolStoreState);
// Get all builtin tool manifests
const builtinManifests = toolStoreState.builtinTools.map(
(tool) => tool.manifest as LobeChatPluginManifest,
);
const builtinManifests = toolStoreState.builtinTools.map((tool) => tool.manifest as ToolManifest);
// Get Klavis tool manifests
const klavisTools = klavisStoreSelectors.klavisAsLobeTools(toolStoreState);
const klavisManifests = klavisTools
.map((tool) => tool.manifest as LobeChatPluginManifest)
.filter(Boolean);
const klavisManifests = klavisTools.map((tool) => tool.manifest as ToolManifest).filter(Boolean);
// Get LobeHub Skill tool manifests
const lobehubSkillTools = lobehubSkillStoreSelectors.lobehubSkillAsLobeTools(toolStoreState);
const lobehubSkillManifests = lobehubSkillTools
.map((tool) => tool.manifest as LobeChatPluginManifest)
.map((tool) => tool.manifest as ToolManifest)
.filter(Boolean);
// Combine all manifests

View file

@ -3,7 +3,7 @@ import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import { ToolNameResolver } from '@lobechat/context-engine';
import { type API } from '@lobechat/prompts';
import { apiPrompt, toolPrompt } from '@lobechat/prompts';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { type IEditor } from '@lobehub/editor';
import { INSERT_MENTION_COMMAND } from '@lobehub/editor';
import { Icon, Image } from '@lobehub/ui';
@ -41,7 +41,7 @@ const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label
const toolNameResolver = new ToolNameResolver();
const buildApiList = (identifier: string, manifest?: LobeChatPluginManifest): API[] => {
const buildApiList = (identifier: string, manifest?: ToolManifest): API[] => {
if (!manifest?.api) return [];
return manifest.api.map((api) => ({
@ -58,7 +58,7 @@ const hydrateSystemRole = (systemRole?: string) => {
const resolveInstructions = (
metadata: MentionMetadata,
manifest?: LobeChatPluginManifest,
manifest?: ToolManifest,
fallbackDesc?: string,
) => {
if (metadata.instructions) return metadata.instructions;
@ -70,7 +70,7 @@ const resolveInstructions = (
const resolveApiName = (
metadata: MentionMetadata,
manifest: LobeChatPluginManifest | undefined,
manifest: ToolManifest | undefined,
pluginId?: string,
fallbackLabel?: string,
) => {
@ -93,7 +93,7 @@ const resolveApiName = (
const resolveApiDescription = (
metadata: MentionMetadata,
manifest: LobeChatPluginManifest | undefined,
manifest: ToolManifest | undefined,
pluginId: string | undefined,
apiName?: string,
) => {

View file

@ -3,7 +3,7 @@ import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import { ToolNameResolver } from '@lobechat/context-engine';
import { type API } from '@lobechat/prompts';
import { apiPrompt, toolPrompt } from '@lobechat/prompts';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { type IEditor } from '@lobehub/editor';
import { INSERT_MENTION_COMMAND } from '@lobehub/editor';
import { Icon, Image } from '@lobehub/ui';
@ -41,7 +41,7 @@ const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label
const toolNameResolver = new ToolNameResolver();
const buildApiList = (identifier: string, manifest?: LobeChatPluginManifest): API[] => {
const buildApiList = (identifier: string, manifest?: ToolManifest): API[] => {
if (!manifest?.api) return [];
return manifest.api.map((api) => ({
@ -58,7 +58,7 @@ const hydrateSystemRole = (systemRole?: string) => {
const resolveInstructions = (
metadata: MentionMetadata,
manifest?: LobeChatPluginManifest,
manifest?: ToolManifest,
fallbackDesc?: string,
) => {
if (metadata.instructions) return metadata.instructions;
@ -70,7 +70,7 @@ const resolveInstructions = (
const resolveApiName = (
metadata: MentionMetadata,
manifest: LobeChatPluginManifest | undefined,
manifest: ToolManifest | undefined,
pluginId?: string,
fallbackLabel?: string,
) => {
@ -93,7 +93,7 @@ const resolveApiName = (
const resolveApiDescription = (
metadata: MentionMetadata,
manifest: LobeChatPluginManifest | undefined,
manifest: ToolManifest | undefined,
pluginId: string | undefined,
apiName?: string,
) => {

View file

@ -10,7 +10,7 @@ import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useServerConfigStore } from '@/store/serverConfig';
import { pluginHelpers, useToolStore } from '@/store/tool';
import { pluginSelectors, pluginStoreSelectors } from '@/store/tool/selectors';
import { mcpStoreSelectors, pluginSelectors } from '@/store/tool/selectors';
import { type LobeToolType } from '@/types/tool/tool';
import EditCustomPlugin from './EditCustomPlugin';
@ -23,15 +23,12 @@ interface ActionsProps {
const Actions = memo<ActionsProps>(({ identifier, type, isMCP }) => {
const mobile = useServerConfigStore((s) => s.isMobile);
const [installed, installing, installPlugin, unInstallPlugin, installMCPPlugin] = useToolStore(
(s) => [
const [installed, installing, unInstallPlugin, installMCPPlugin] = useToolStore((s) => [
pluginSelectors.isPluginInstalled(identifier)(s),
pluginStoreSelectors.isPluginInstallLoading(identifier)(s),
s.installPlugin,
s.uninstallPlugin,
mcpStoreSelectors.isPluginInstallLoading(identifier)(s),
s.uninstallCustomPlugin,
s.installMCPPlugin,
],
);
]);
const isCustomPlugin = type === 'customPlugin';
const { t } = useTranslation('plugin');
@ -116,9 +113,6 @@ const Actions = memo<ActionsProps>(({ identifier, type, isMCP }) => {
if (isMCP) {
await installMCPPlugin(identifier);
await togglePlugin(identifier);
} else {
await installPlugin(identifier);
await togglePlugin(identifier);
}
}}
>

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { z } from 'zod';
import { PluginModel } from '@/database/models/plugin';
@ -49,7 +49,7 @@ export const klavisRouter = router({
const tools = toolsResponse.tools || [];
// Save to database using the provided identifier (format: lowercase, spaces replaced with hyphens)
const manifest: LobeChatPluginManifest = {
const manifest: ToolManifest = {
api: tools.map((tool: any) => ({
description: tool.description || '',
name: tool.name,
@ -232,7 +232,7 @@ export const klavisRouter = router({
const existingPlugin = await ctx.pluginModel.findById(identifier);
// Build manifest containing all tools
const manifest: LobeChatPluginManifest = {
const manifest: ToolManifest = {
api: tools.map((tool) => ({
description: tool.description || '',
name: tool.name,

View file

@ -57,14 +57,6 @@ vi.mock('@/server/services/search', () => ({
},
}));
// Mock plugin gateway service to avoid server-side env access
vi.mock('@/server/services/pluginGateway', () => ({
PluginGatewayService: vi.fn().mockImplementation(() => ({
getPluginManifest: vi.fn(),
executePlugin: vi.fn(),
})),
}));
// Mock factory and redis dependencies to break env import chains,
// so the barrel can be imported with real AgentRuntimeCoordinator + InMemory backends
vi.mock('@/server/modules/AgentRuntime/factory', async () => {

View file

@ -15,7 +15,6 @@ import { type RuntimeExecutorContext } from '@/server/modules/AgentRuntime/Runti
import { createRuntimeExecutors } from '@/server/modules/AgentRuntime/RuntimeExecutors';
import { type IStreamEventManager } from '@/server/modules/AgentRuntime/types';
import { mcpService } from '@/server/services/mcp';
import { PluginGatewayService } from '@/server/services/pluginGateway';
import { QueueService } from '@/server/services/queue';
import { LocalQueueServiceImpl } from '@/server/services/queue/impls';
import { ToolExecutionService } from '@/server/services/toolExecution';
@ -160,13 +159,11 @@ export class AgentRuntimeService {
this.messageModel = new MessageModel(db, this.userId);
// Initialize ToolExecutionService with dependencies
const pluginGatewayService = new PluginGatewayService();
const builtinToolsExecutor = new BuiltinToolsExecutor(db, userId);
this.toolExecutionService = new ToolExecutionService({
builtinToolsExecutor,
mcpService,
pluginGatewayService,
});
// Setup local execution callback for LocalQueueServiceImpl

View file

@ -36,14 +36,6 @@ vi.mock('@/server/services/search', () => ({
},
}));
// Mock plugin gateway service
vi.mock('@/server/services/pluginGateway', () => ({
PluginGatewayService: vi.fn().mockImplementation(() => ({
executePlugin: vi.fn(),
getPluginManifest: vi.fn(),
})),
}));
// Mock MCP service
vi.mock('@/server/services/mcp', () => ({
mcpService: {

View file

@ -29,9 +29,6 @@ vi.mock('@/server/modules/AgentRuntime/RuntimeExecutors', () => ({
createRuntimeExecutors: vi.fn(() => ({})),
}));
vi.mock('@/server/services/mcp', () => ({ mcpService: {} }));
vi.mock('@/server/services/pluginGateway', () => ({
PluginGatewayService: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('@/server/services/queue', () => ({
QueueService: vi.fn().mockImplementation(() => ({
getImpl: vi.fn(() => ({})),

View file

@ -36,14 +36,6 @@ vi.mock('@/server/services/search', () => ({
},
}));
// Mock plugin gateway service
vi.mock('@/server/services/pluginGateway', () => ({
PluginGatewayService: vi.fn().mockImplementation(() => ({
getPluginManifest: vi.fn(),
executePlugin: vi.fn(),
})),
}));
// Mock MCP service
vi.mock('@/server/services/mcp', () => ({
mcpService: {

View file

@ -37,14 +37,6 @@ vi.mock('@/server/services/search', () => ({
},
}));
// Mock plugin gateway service
vi.mock('@/server/services/pluginGateway', () => ({
PluginGatewayService: vi.fn().mockImplementation(() => ({
executePlugin: vi.fn(),
getPluginManifest: vi.fn(),
})),
}));
// Mock MCP service
vi.mock('@/server/services/mcp', () => ({
mcpService: {

View file

@ -1,10 +1,11 @@
import { type CheckMcpInstallResult, type CustomPluginMetadata } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import {
type CheckMcpInstallResult,
type CustomPluginMetadata,
type LobeChatPluginApi,
type LobeChatPluginManifest,
type PluginSchema,
} from '@lobehub/chat-plugin-sdk';
type ToolManifest,
type ToolManifestSettings,
} from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import { type DeploymentOption } from '@lobehub/market-sdk';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { TRPCError } from '@trpc/server';
@ -111,7 +112,7 @@ export class MCPService {
// Assuming identifier is the unique name/id
description: item.description,
name: item.name,
parameters: item.inputSchema as PluginSchema,
parameters: item.inputSchema as ToolManifestSettings,
}));
} catch (error) {
// Only retry for NoValidSessionId errors
@ -355,7 +356,7 @@ export class MCPService {
type: 'none' | 'bearer' | 'oauth2';
},
headers?: Record<string, string>,
): Promise<LobeChatPluginManifest> {
): Promise<ToolManifest> {
const mcpParams = { name: identifier, type: 'http' as const, url };
// Add authentication info to parameters if available
@ -390,7 +391,7 @@ export class MCPService {
async getStdioMcpServerManifest(
params: Omit<StdioMCPParams, 'type'>,
metadata?: CustomPluginMetadata,
): Promise<LobeChatPluginManifest> {
): Promise<ToolManifest> {
const mcpParams = {
args: params.args,
command: params.command,
@ -424,7 +425,7 @@ export class MCPService {
mcpParams,
// TODO: temporary
type: 'mcp' as any,
} as LobeChatPluginManifest;
} as ToolManifest;
}
/**
@ -490,7 +491,7 @@ export class MCPService {
// Assuming identifier is the unique name/id
description: item.description,
name: item.name,
parameters: item.inputSchema as PluginSchema,
parameters: item.inputSchema as ToolManifestSettings,
}));
};
}

View file

@ -1,61 +0,0 @@
import { type ChatToolPayload } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import { type PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { type GatewaySuccessResponse } from '@lobehub/chat-plugins-gateway';
import debug from 'debug';
import { getAppConfig } from '@/envs/app';
import { parserPluginSettings } from '@/server/services/pluginGateway/settings';
import { type ToolExecutionContext } from '@/server/services/toolExecution/types';
const log = debug('lobe-server:plugin-gateway-service');
export class PluginGatewayService {
params: { PLUGINS_INDEX_URL: string; PLUGIN_SETTINGS: string | undefined };
constructor() {
const { PLUGINS_INDEX_URL, PLUGIN_SETTINGS } = getAppConfig();
this.params = { PLUGINS_INDEX_URL, PLUGIN_SETTINGS };
}
async execute(payload: ChatToolPayload, context: ToolExecutionContext) {
const { identifier, apiName, arguments: argsStr } = payload;
const args = safeParseJSON(argsStr) || {};
log('Executing plugin: %s:%s with args: %O', identifier, apiName, args, context);
try {
// Construct plugin request
const requestBody: PluginRequestPayload = {
apiName,
arguments: JSON.stringify(args),
identifier,
manifest: context.toolManifestMap[identifier] as any,
};
const { Gateway } = await import('@lobehub/chat-plugins-gateway');
const gateway = new Gateway({
defaultPluginSettings: parserPluginSettings(this.params.PLUGIN_SETTINGS),
pluginsIndexUrl: this.params.PLUGINS_INDEX_URL,
});
const response = await gateway.execute(requestBody);
log('Plugin execution result: %O', response);
return {
content: (response as GatewaySuccessResponse).data,
success: true,
};
} catch (error) {
console.error('Error executing plugin %s:%s: %O', identifier, apiName, error);
return {
content: (error as Error).message,
error: {
message: (error as Error).message,
},
success: false,
};
}
}
}

View file

@ -1,103 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parserPluginSettings } from './settings';
describe('parserPluginSettings', () => {
it('should return an empty object when input is undefined', () => {
expect(parserPluginSettings()).toEqual({});
});
it('should return an empty object when input is an empty string', () => {
expect(parserPluginSettings('')).toEqual({});
});
it('should parse plugin settings from a well-formed string', () => {
const input = 'plugin1:key1=value1;key2=value2,plugin2:key3=value3';
const expected = {
plugin1: { key1: 'value1', key2: 'value2' },
plugin2: { key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should handle strings with Chinese commas', () => {
const input = 'plugin1:key1=value1;key2=value2plugin2:key3=value3';
const expected = {
plugin1: { key1: 'value1', key2: 'value2' },
plugin2: { key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should ignore empty segments', () => {
const input = 'plugin1:key1=value1;key2=value2,,,plugin2:key3=value3';
const expected = {
plugin1: { key1: 'value1', key2: 'value2' },
plugin2: { key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should merge settings for the same pluginId', () => {
const input = 'plugin1:key1=value1;key2=value2,plugin1:key3=value3;key4=value4';
const expected = {
plugin1: { key1: 'value1', key2: 'value2', key3: 'value3', key4: 'value4' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should override previous values if the same key appears again for the same pluginId', () => {
const input = 'plugin1:key1=value1;key2=value2,plugin1:key2=newValue2;key3=value3';
const expected = {
plugin1: { key1: 'value1', key2: 'newValue2', key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
describe('error senses', () => {
it('should ignore settings with incorrect key-value format', () => {
const input = 'plugin1:key1=value1;incorrectFormat,plugin2:key2=value2';
const expected = {
plugin1: { key1: 'value1' },
plugin2: { key2: 'value2' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should handle extra separators gracefully', () => {
const input = 'plugin1:key1=value1==value1.1;key2=value2;,plugin2:key3=value3';
const expected = {
plugin1: { key1: 'value1', key2: 'value2' },
plugin2: { key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should ignore settings with empty keys or values', () => {
const input = 'plugin1:=value1;key2=,plugin2:key3=value3';
const expected = {
plugin2: { key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should ignore leading and trailing whitespace in keys and values', () => {
const input = ' plugin1 : key1 = value1 ; key2 = value2 , plugin2 : key3=value3 ';
const expected = {
plugin1: { key1: 'value1', key2: 'value2' },
plugin2: { key3: 'value3' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
it('should handle special characters in keys and values', () => {
const input = 'plugin1:key1=value1+special;key2=value2#special,plugin2:key3=value3/special';
const expected = {
plugin1: { key1: 'value1+special', key2: 'value2#special' },
plugin2: { key3: 'value3/special' },
};
expect(parserPluginSettings(input)).toEqual(expected);
});
});
});

View file

@ -1,29 +0,0 @@
export const parserPluginSettings = (
settingsStr?: string,
): Record<string, Record<string, string>> => {
if (!settingsStr) return {};
const settings = new Map<string, Record<string, string>>();
const array = settingsStr.split(/[,]/).filter(Boolean);
for (const item of array) {
const [id, pluginSettingsStr] = item.split(':');
if (!id) continue;
const pluginSettingItems = pluginSettingsStr.split(';');
const cleanId = id.trim();
for (const item of pluginSettingItems) {
const [key, value] = item.split('=');
if (!key || !value) continue;
const cleanKey = key.trim();
const cleanValue = value.trim();
settings.set(cleanId, { ...settings.get(cleanId), [cleanKey]: cleanValue });
}
}
return Object.fromEntries(settings.entries());
};

View file

@ -11,7 +11,6 @@ import {
import { DiscoverService } from '../discover';
import { type MCPService } from '../mcp';
import { type PluginGatewayService } from '../pluginGateway';
import { type BuiltinToolsExecutor } from './builtin';
import { classifyToolError } from './errorClassification';
import {
@ -25,7 +24,6 @@ const log = debug('lobe-server:tool-execution-service');
interface ToolExecutionServiceDeps {
builtinToolsExecutor: BuiltinToolsExecutor;
mcpService: MCPService;
pluginGatewayService: PluginGatewayService;
}
const normalizeExecutionError = (error: unknown, fallbackMessage: string) => {
@ -62,16 +60,10 @@ const normalizeExecutionError = (error: unknown, fallbackMessage: string) => {
export class ToolExecutionService {
private builtinToolsExecutor: BuiltinToolsExecutor;
private mcpService: MCPService;
private pluginGatewayService: PluginGatewayService;
constructor({
mcpService,
pluginGatewayService,
builtinToolsExecutor,
}: ToolExecutionServiceDeps) {
constructor({ mcpService, builtinToolsExecutor }: ToolExecutionServiceDeps) {
this.builtinToolsExecutor = builtinToolsExecutor;
this.mcpService = mcpService;
this.pluginGatewayService = pluginGatewayService;
}
async executeTool(
@ -87,19 +79,14 @@ export class ToolExecutionService {
const typeStr = type as string;
let data: ToolExecutionResult;
switch (typeStr) {
case 'builtin': {
data = await this.builtinToolsExecutor.execute(payload, context);
break;
}
case 'mcp': {
data = await this.executeMCPTool(payload, context);
break;
}
case 'builtin':
default: {
data = await this.pluginGatewayService.execute(payload, context);
data = await this.builtinToolsExecutor.execute(payload, context);
break;
}
}

View file

@ -1,79 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolService > can parse the OpenAI plugin 1`] = `
{
"api": [],
"homepage": "https://products.wolframalpha.com/api/commercial-termsofuse",
"identifier": "Wolfram",
"meta": {
"avatar": "https://www.wolframcdn.com/images/icons/Wolfram.png",
"description": "Access computation, math, curated knowledge & real-time data through Wolfram|Alpha and Wolfram Language.",
"title": "Wolfram",
},
"openapi": "https://www.wolframalpha.com/.well-known/apispec.json",
"settings": {
"properties": {
"apiAuthKey": {
"default": "18c4412dec6846eda6ec2fa95f144e1f",
"description": "API Key",
"format": "password",
"title": "API Key",
"type": "string",
},
},
"type": "object",
},
"systemRole": "Access dynamic computation and curated data from WolframAlpha and Wolfram Cloud.
General guidelines:
- Use only getWolframAlphaResults or getWolframCloudResults endpoints.
- Prefer getWolframAlphaResults unless Wolfram Language code should be evaluated.
- Use getWolframAlphaResults for natural-language queries in English; translate non-English queries before sending, then respond in the original language.
- Use getWolframCloudResults for problems solvable with Wolfram Language code.
- Suggest only Wolfram Language for external computation.
- Inform users if information is not from Wolfram endpoints.
- Display image URLs with Markdown syntax: ![URL]
- ALWAYS use this exponent notation: \`6*10^14\`, NEVER \`6e14\`.
- ALWAYS use {"input": query} structure for queries to Wolfram endpoints; \`query\` must ONLY be a single-line string.
- ALWAYS use proper Markdown formatting for all math, scientific, and chemical formulas, symbols, etc.: '$$\\n[expression]\\n$$' for standalone cases and '\\( [expression] \\)' when inline.
- Format inline Wolfram Language code with Markdown code formatting.
- Never mention your knowledge cutoff date; Wolfram may return more recent data.
getWolframAlphaResults guidelines:
- Understands natural language queries about entities in chemistry, physics, geography, history, art, astronomy, and more.
- Performs mathematical calculations, date and unit conversions, formula solving, etc.
- Convert inputs to simplified keyword queries whenever possible (e.g. convert "how many people live in France" to "France population").
- Use ONLY single-letter variable names, with or without integer subscript (e.g., n, n1, n_1).
- Use named physical constants (e.g., 'speed of light') without numerical substitution.
- Include a space between compound units (e.g., "Ω m" for "ohm*meter").
- To solve for a variable in an equation with units, consider solving a corresponding equation without units; exclude counting units (e.g., books), include genuine units (e.g., kg).
- If data for multiple properties is needed, make separate calls for each property.
- If a Wolfram Alpha result is not relevant to the query:
-- If Wolfram provides multiple 'Assumptions' for a query, choose the more relevant one(s) without explaining the initial result. If you are unsure, ask the user to choose.
-- Re-send the exact same 'input' with NO modifications, and add the 'assumption' parameter, formatted as a list, with the relevant values.
-- ONLY simplify or rephrase the initial query if a more relevant 'Assumption' or other input suggestions are not provided.
-- Do not explain each step unless user input is needed. Proceed directly to making a better API call based on the available assumptions.
getWolframCloudResults guidelines:
- Accepts only syntactically correct Wolfram Language code.
- Performs complex calculations, data analysis, plotting, data import, and information retrieval.
- Before writing code that uses Entity, EntityProperty, EntityClass, etc. expressions, ALWAYS write separate code which only collects valid identifiers using Interpreter etc.; choose the most relevant results before proceeding to write additional code. Examples:
-- Find the EntityType that represents countries: \`Interpreter["EntityType",AmbiguityFunction->All]["countries"]\`.
-- Find the Entity for the Empire State Building: \`Interpreter["Building",AmbiguityFunction->All]["empire state"]\`.
-- EntityClasses: Find the "Movie" entity class for Star Trek movies: \`Interpreter["MovieClass",AmbiguityFunction->All]["star trek"]\`.
-- Find EntityProperties associated with "weight" of "Element" entities: \`Interpreter[Restricted["EntityProperty", "Element"],AmbiguityFunction->All]["weight"]\`.
-- If all else fails, try to find any valid Wolfram Language representation of a given input: \`SemanticInterpretation["skyscrapers",_,Hold,AmbiguityFunction->All]\`.
-- Prefer direct use of entities of a given type to their corresponding typeData function (e.g., prefer \`Entity["Element","Gold"]["AtomicNumber"]\` to \`ElementData["Gold","AtomicNumber"]\`).
- When composing code:
-- Use batching techniques to retrieve data for multiple entities in a single call, if applicable.
-- Use Association to organize and manipulate data when appropriate.
-- Optimize code for performance and minimize the number of calls to external sources (e.g., the Wolfram Knowledgebase)
-- Use only camel case for variable names (e.g., variableName).
-- Use ONLY double quotes around all strings, including plot labels, etc. (e.g., \`PlotLegends -> {"sin(x)", "cos(x)", "tan(x)"}\`).
-- Avoid use of QuantityMagnitude.
-- If unevaluated Wolfram Language symbols appear in API results, use \`EntityValue[Entity["WolframLanguageSymbol",symbol],{"PlaintextUsage","Options"}]\` to validate or retrieve usage information for relevant symbols; \`symbol\` may be a list of symbols.
-- Apply Evaluate to complex expressions like integrals before plotting (e.g., \`Plot[Evaluate[Integrate[...]]]\`).
- Remove all comments and formatting from code passed to the "input" parameter; for example: instead of \`square[x_] := Module[{result},\\n result = x^2 (* Calculate the square *)\\n]\`, send \`square[x_]:=Module[{result},result=x^2]\`.
- In ALL responses that involve code, write ALL code in Wolfram Language; create Wolfram Language functions even if an implementation is already well known in another language.
",
"type": "default",
"version": "1",
}
`;

View file

@ -6,7 +6,6 @@ describe('API_ENDPOINTS', () => {
it('should return correct basePath URLs', () => {
expect(API_ENDPOINTS.oauth).toBe('/api/auth');
expect(API_ENDPOINTS.proxy).toBe('/webapi/proxy');
expect(API_ENDPOINTS.gateway).toBe('/webapi/plugin/gateway');
expect(API_ENDPOINTS.trace).toBe('/webapi/trace');
expect(API_ENDPOINTS.stt).toBe('/webapi/stt/openai');
expect(API_ENDPOINTS.edge).toBe('/webapi/tts/edge');

View file

@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toolService } from '../tool';
import OpenAIPlugin from './openai/plugin.json';
// Mocking modules and functions
@ -31,7 +30,6 @@ describe('ToolService', () => {
const manifestUrl = 'http://fake-url.com/manifest.json';
const fakeManifest = {
$schema: '../node_modules/@lobehub/chat-plugin-sdk/schema.json',
api: [
{
url: 'https://realtime-weather.chat-plugin.lobehub.com/api/v1',
@ -136,10 +134,4 @@ describe('ToolService', () => {
}
});
});
it('can parse the OpenAI plugin', async () => {
const manifest = toolService['convertOpenAIManifestToLobeManifest'](OpenAIPlugin as any);
expect(manifest).toMatchSnapshot();
});
});

View file

@ -5,9 +5,6 @@ export const API_ENDPOINTS = {
proxy: withElectronProtocolIfElectron('/webapi/proxy'),
// plugins
gateway: withElectronProtocolIfElectron('/webapi/plugin/gateway'),
// trace
trace: withElectronProtocolIfElectron('/webapi/trace'),

View file

@ -1536,23 +1536,6 @@ describe('ChatService', () => {
// Add more test cases to cover different scenarios and edge cases
});
describe('runPluginApi', () => {
it('should make a POST request and return the result text', async () => {
const params = { identifier: 'test-plugin', apiName: '1' }; // Add more properties if needed
const options = {};
const mockResponse = new Response('Plugin Result', { status: 200 });
global.fetch = vi.fn(() => Promise.resolve(mockResponse));
const result = await chatService.runPluginApi(params, options);
expect(global.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
expect(result.text).toBe('Plugin Result');
});
// Add more test cases to cover different scenarios and edge cases
});
describe('fetchPresetTaskResult', () => {
it('should handle successful chat completion response', async () => {
// Mock getChatCompletion to simulate successful completion

View file

@ -2,7 +2,7 @@ import { AgentBuilderIdentifier } from '@lobechat/builtin-tool-agent-builder';
import { KLAVIS_SERVER_TYPES, LOBEHUB_SKILL_PROVIDERS } from '@lobechat/const';
import { type OfficialToolItem } from '@lobechat/context-engine';
import { type FetchSSEOptions } from '@lobechat/fetch-sse';
import { fetchSSE, getMessageError, standardizeAnimationStyle } from '@lobechat/fetch-sse';
import { fetchSSE, standardizeAnimationStyle } from '@lobechat/fetch-sse';
import { type ChatCompletionErrorPayload } from '@lobechat/model-runtime';
import { AgentRuntimeError } from '@lobechat/model-runtime';
import {
@ -12,8 +12,6 @@ import {
type UIChatMessage,
} from '@lobechat/types';
import { ChatErrorType, TraceTagMap } from '@lobechat/types';
import { type PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { createHeadersWithPluginSettings } from '@lobehub/chat-plugin-sdk';
import { merge } from 'es-toolkit/compat';
import { ModelProvider } from 'model-bank';
@ -32,7 +30,6 @@ import {
builtinToolSelectors,
klavisStoreSelectors,
lobehubSkillStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
import { getUserStoreState, useUserStore } from '@/store/user';
import {
@ -42,7 +39,7 @@ import {
} from '@/store/user/selectors';
import { type ChatStreamPayload, type OpenAIChatMessage } from '@/types/openai/chat';
import { createErrorResponse } from '@/utils/errorResponse';
import { createTraceHeader, getTraceId } from '@/utils/trace';
import { createTraceHeader } from '@/utils/trace';
import { createHeaderWithAuth } from '../_auth';
import { API_ENDPOINTS } from '../_url';
@ -446,40 +443,6 @@ class ChatService {
});
};
/**
* run the plugin api to get result
* @param params
* @param options
*/
runPluginApi = async (params: PluginRequestPayload, options?: FetchOptions) => {
const s = getToolStoreState();
const settings = pluginSelectors.getPluginSettingsById(params.identifier)(s);
const manifest = pluginSelectors.getToolManifestById(params.identifier)(s);
const traceHeader = createTraceHeader(this.mapTrace(options?.trace, TraceTagMap.ToolCalling));
const headers = await createHeaderWithAuth({
headers: { ...createHeadersWithPluginSettings(settings), ...traceHeader },
});
const gatewayURL = manifest?.gateway ?? API_ENDPOINTS.gateway;
const res = await fetch(gatewayURL, {
body: JSON.stringify({ ...params, manifest }),
headers,
method: 'POST',
signal: options?.signal,
});
if (!res.ok) {
throw await getMessageError(res);
}
const text = await res.text();
return { text, traceId: getTraceId(res) };
};
fetchPresetTaskResult = async ({
params,
onMessageHandle,

View file

@ -1,5 +1,4 @@
import { type ChatToolPayload } from '@lobechat/types';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ChatToolPayload, type ToolManifest } from '@lobechat/types';
import superjson from 'superjson';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@ -438,7 +437,7 @@ describe('MCPService', () => {
describe('getStreamableMcpServerManifest', () => {
it('should use toolsClient for streamable URLs when not on desktop', async () => {
const { toolsClient } = await import('@/libs/trpc/client');
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'streamable-server',
version: '1',
meta: { title: 'Streamable MCP Server', avatar: '🌐' },
@ -470,7 +469,7 @@ describe('MCPService', () => {
it('should use toolsClient for remote URLs', async () => {
const { toolsClient } = await import('@/libs/trpc/client');
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'remote-server',
version: '1',
meta: { title: 'Remote MCP Server', avatar: '🌍' },
@ -507,7 +506,7 @@ describe('MCPService', () => {
it('should handle different URL formats correctly', async () => {
const { toolsClient } = await import('@/libs/trpc/client');
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'server',
version: '1',
meta: { title: 'URL Test Server', avatar: '🔗' },
@ -537,7 +536,7 @@ describe('MCPService', () => {
it('should handle OAuth2 authentication', async () => {
const { toolsClient } = await import('@/libs/trpc/client');
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'oauth-server',
version: '1',
meta: { title: 'OAuth Server', avatar: '🔐' },
@ -579,7 +578,7 @@ describe('MCPService', () => {
describe('getStdioMcpServerManifest', () => {
it('should call ipc mcp.getStdioMcpServerManifest with stdio parameters', async () => {
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'stdio-server',
version: '1',
meta: { title: 'Stdio Server', avatar: '📦' },
@ -616,7 +615,7 @@ describe('MCPService', () => {
});
it('should handle abort signal for stdio manifest', async () => {
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'python-server',
version: '1',
meta: { title: 'Stdio Server', avatar: '🐍' },
@ -650,7 +649,7 @@ describe('MCPService', () => {
});
it('should work without optional parameters', async () => {
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
identifier: 'npm-server',
version: '1',
meta: { title: 'Simple Server', avatar: '📦' },

View file

@ -1,5 +1,4 @@
import { type LobeTool } from '@lobechat/types';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type LobeTool, type ToolManifest } from '@lobechat/types';
import { lambdaClient } from '@/libs/trpc/client';
import { type LobeToolCustomPlugin } from '@/types/tool/plugin';
@ -7,7 +6,7 @@ import { type LobeToolCustomPlugin } from '@/types/tool/plugin';
export interface InstallPluginParams {
customParams?: Record<string, any>;
identifier: string;
manifest: LobeChatPluginManifest;
manifest: ToolManifest;
settings?: Record<string, any>;
type: 'plugin' | 'customPlugin';
}
@ -38,7 +37,7 @@ export class PluginService {
});
};
updatePluginManifest = async (id: string, manifest: LobeChatPluginManifest): Promise<void> => {
updatePluginManifest = async (id: string, manifest: ToolManifest): Promise<void> => {
await lambdaClient.plugin.updatePlugin.mutate({ id, manifest });
};

View file

@ -1,7 +1,7 @@
import { lambdaClient } from '@/libs/trpc/client';
import { globalHelpers } from '@/store/global/helpers';
import { type PluginQueryParams } from '@/types/discover';
import { convertOpenAIManifestToLobeManifest, getToolManifest } from '@/utils/toolManifest';
import { getToolManifest } from '@/utils/toolManifest';
class ToolService {
getOldPluginList = async (params: PluginQueryParams): Promise<any> => {
@ -16,7 +16,6 @@ class ToolService {
};
getToolManifest = getToolManifest;
convertOpenAIManifestToLobeManifest = convertOpenAIManifestToLobeManifest;
}
export const toolService = new ToolService();

View file

@ -6,15 +6,12 @@ import i18n from 'i18next';
import { type Mock } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { chatService } from '@/services/chat';
import { messageService } from '@/services/message';
import { chatSelectors } from '@/store/chat/selectors';
import { useChatStore } from '@/store/chat/store';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { useToolStore } from '@/store/tool';
const invokeStandaloneTypePlugin = useChatStore.getState().invokeStandaloneTypePlugin;
vi.mock('zustand/traditional');
// Mock messageService
@ -189,75 +186,6 @@ describe('ChatPluginAction', () => {
});
});
describe('invokeDefaultTypePlugin', () => {
it('should run the default plugin type and update message content', async () => {
const pluginPayload = { apiName: 'testApi', arguments: { key: 'value' } };
const messageId = 'message-id';
const pluginApiResponse = 'Plugin API response';
const storeState = useChatStore.getState();
vi.spyOn(storeState, 'refreshMessages');
vi.spyOn(storeState, 'triggerAIMessage').mockResolvedValue(undefined);
vi.spyOn(storeState, 'optimisticUpdateMessageContent').mockResolvedValue();
const runSpy = vi.spyOn(chatService, 'runPluginApi').mockResolvedValue({
text: pluginApiResponse,
traceId: '',
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.invokeDefaultTypePlugin(messageId, pluginPayload);
});
expect(runSpy).toHaveBeenCalledWith(pluginPayload, { signal: undefined, trace: {} });
expect(storeState.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
pluginApiResponse,
undefined,
undefined,
);
});
it('should handle errors when the plugin API call fails', async () => {
const pluginPayload = { apiName: 'testApi', arguments: { key: 'value' } };
const messageId = 'message-id';
const error = new Error('API call failed');
const mockMessages = [{ id: 'msg-1', content: 'test' }] as any;
// Mock the service to return messages
(messageService.updateMessageError as Mock).mockResolvedValue({
success: true,
messages: mockMessages,
});
const storeState = useChatStore.getState();
const replaceMessagesSpy = vi.spyOn(storeState, 'replaceMessages');
vi.spyOn(storeState, 'triggerAIMessage').mockResolvedValue(undefined);
vi.spyOn(chatService, 'runPluginApi').mockRejectedValue(error);
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.invokeDefaultTypePlugin(messageId, pluginPayload);
});
expect(chatService.runPluginApi).toHaveBeenCalledWith(pluginPayload, { trace: {} });
// Context now includes groupId from the message
expect(messageService.updateMessageError).toHaveBeenCalledWith(
messageId,
error,
expect.objectContaining({ topicId: undefined }),
);
expect(replaceMessagesSpy).toHaveBeenCalledWith(mockMessages, {
context: expect.objectContaining({ topicId: undefined }),
});
expect(storeState.triggerAIMessage).not.toHaveBeenCalled(); // 确保在错误情况下不调用此方法
});
});
describe('updatePluginState', () => {
it('should update the plugin state for a message', async () => {
const messageId = 'message-id';
@ -670,84 +598,6 @@ describe('ChatPluginAction', () => {
});
});
describe('invokeMarkdownTypePlugin', () => {
it('should invoke a markdown type plugin', async () => {
const payload = {
apiName: 'markdownApi',
identifier: 'abc',
type: 'markdown',
arguments: JSON.stringify({ key: 'value' }),
} as ChatToolPayload;
const messageId = 'message-id';
const runPluginApiMock = vi.fn();
act(() => {
useChatStore.setState({ internal_callPluginApi: runPluginApiMock });
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.invokeMarkdownTypePlugin(messageId, payload);
});
// Verify that the markdown type plugin was invoked
expect(runPluginApiMock).toHaveBeenCalledWith(messageId, payload);
});
});
describe('invokeStandaloneTypePlugin', () => {
it('should update message with error and refresh messages if plugin settings are invalid', async () => {
const messageId = 'message-id';
const mockMessages = [{ id: 'msg-1', content: 'test' }] as any;
const payload = {
identifier: 'pluginName',
} as ChatToolPayload;
// Mock the service to return messages
(messageService.updateMessageError as Mock).mockResolvedValue({
success: true,
messages: mockMessages,
});
const replaceMessagesSpy = vi.fn();
act(() => {
useToolStore.setState({
validatePluginSettings: vi
.fn()
.mockResolvedValue({ valid: false, errors: ['Invalid setting'] }),
});
useChatStore.setState({ replaceMessages: replaceMessagesSpy, invokeStandaloneTypePlugin });
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.invokeStandaloneTypePlugin(messageId, payload);
});
const call = vi.mocked(messageService.updateMessageError).mock.calls[0];
expect(call[1]).toEqual({
body: {
error: ['Invalid setting'],
message: '[plugin] your settings is invalid with plugin manifest setting schema',
},
message: 'response.PluginSettingsInvalid',
type: 'PluginSettingsInvalid',
});
// Context now includes groupId from the message
expect(replaceMessagesSpy).toHaveBeenCalledWith(mockMessages, {
context: expect.objectContaining({ topicId: undefined }),
});
});
});
describe('reInvokeToolMessage', () => {
it('should re-invoke a tool message', async () => {
const messageId = 'message-id';
@ -876,92 +726,6 @@ describe('ChatPluginAction', () => {
});
});
describe('internal_callPluginApi', () => {
it('should call plugin API and update message content', async () => {
const messageId = 'message-id';
const payload: ChatToolPayload = {
id: 'tool-id',
type: 'default',
identifier: 'plugin-id',
apiName: 'api-name',
arguments: '{}',
};
const apiResponse = 'API response';
vi.spyOn(chatService, 'runPluginApi').mockResolvedValue({
text: apiResponse,
traceId: 'trace-id',
});
act(() => {
useChatStore.setState({
optimisticUpdateMessageContent: vi.fn(),
refreshMessages: vi.fn(),
});
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.internal_callPluginApi(messageId, payload);
});
expect(chatService.runPluginApi).toHaveBeenCalledWith(payload, expect.any(Object));
expect(result.current.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
apiResponse,
undefined,
undefined,
);
expect(messageService.updateMessage).toHaveBeenCalledWith(messageId, { traceId: 'trace-id' });
});
it('should handle API call errors', async () => {
const messageId = 'message-id';
const payload: ChatToolPayload = {
id: 'tool-id',
type: 'default',
identifier: 'plugin-id',
apiName: 'api-name',
arguments: '{}',
};
const error = new Error('API call failed');
const mockMessages = [{ id: 'msg-1', content: 'test' }] as any;
// Mock the service to return messages
(messageService.updateMessageError as Mock).mockResolvedValue({
success: true,
messages: mockMessages,
});
vi.spyOn(chatService, 'runPluginApi').mockRejectedValue(error);
const replaceMessagesSpy = vi.fn();
act(() => {
useChatStore.setState({
replaceMessages: replaceMessagesSpy,
});
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.internal_callPluginApi(messageId, payload);
});
// Context now includes groupId from the message
expect(messageService.updateMessageError).toHaveBeenCalledWith(
messageId,
error,
expect.objectContaining({ topicId: undefined }),
);
expect(replaceMessagesSpy).toHaveBeenCalledWith(mockMessages, {
context: expect.objectContaining({ topicId: undefined }),
});
});
});
describe('internal_transformToolCalls', () => {
it('should transform tool calls correctly', () => {
const toolCalls: MessageToolCall[] = [

View file

@ -1,7 +1,6 @@
import { builtinTools } from '@lobechat/builtin-tools';
import { ToolArgumentsRepairer, ToolNameResolver } from '@lobechat/context-engine';
import { type ChatToolPayload, type MessageToolCall } from '@lobechat/types';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ChatToolPayload, type MessageToolCall, type ToolManifest } from '@lobechat/types';
import { type ChatStore } from '@/store/chat/store';
import { useToolStore } from '@/store/tool';
@ -33,7 +32,7 @@ export class PluginInternalsActionImpl {
// Build manifests map from tool store
const toolStoreState = useToolStore.getState();
const manifests: Record<string, LobeChatPluginManifest> = {};
const manifests: Record<string, ToolManifest> = {};
// Track source for each identifier
const sourceMap: Record<string, 'builtin' | 'plugin' | 'mcp' | 'klavis' | 'lobehubSkill'> = {};
@ -42,7 +41,7 @@ export class PluginInternalsActionImpl {
const installedPlugins = pluginSelectors.installedPlugins(toolStoreState);
for (const plugin of installedPlugins) {
if (plugin.manifest) {
manifests[plugin.identifier] = plugin.manifest as LobeChatPluginManifest;
manifests[plugin.identifier] = plugin.manifest as ToolManifest;
// Check if this plugin has MCP params
sourceMap[plugin.identifier] = plugin.customParams?.mcp ? 'mcp' : 'plugin';
}
@ -51,7 +50,7 @@ export class PluginInternalsActionImpl {
// Get all builtin tools
for (const tool of builtinTools) {
if (tool.manifest) {
manifests[tool.identifier] = tool.manifest as LobeChatPluginManifest;
manifests[tool.identifier] = tool.manifest as ToolManifest;
sourceMap[tool.identifier] = 'builtin';
}
}
@ -60,7 +59,7 @@ export class PluginInternalsActionImpl {
const klavisTools = klavisStoreSelectors.klavisAsLobeTools(toolStoreState);
for (const tool of klavisTools) {
if (tool.manifest) {
manifests[tool.identifier] = tool.manifest as LobeChatPluginManifest;
manifests[tool.identifier] = tool.manifest as ToolManifest;
sourceMap[tool.identifier] = 'klavis';
}
}
@ -69,7 +68,7 @@ export class PluginInternalsActionImpl {
const lobehubSkillTools = lobehubSkillStoreSelectors.lobehubSkillAsLobeTools(toolStoreState);
for (const tool of lobehubSkillTools) {
if (tool.manifest) {
manifests[tool.identifier] = tool.manifest as LobeChatPluginManifest;
manifests[tool.identifier] = tool.manifest as ToolManifest;
sourceMap[tool.identifier] = 'lobehubSkill';
}
}

View file

@ -1,11 +1,8 @@
import { type ChatToolPayload, type RuntimeStepContext } from '@lobechat/types';
import { PluginErrorType } from '@lobehub/chat-plugin-sdk';
import debug from 'debug';
import { t } from 'i18next';
import { type MCPToolCallResult } from '@/libs/mcp';
import { truncateToolResult } from '@/server/utils/truncateToolResult';
import { chatService } from '@/services/chat';
import { mcpService } from '@/services/mcp';
import { messageService } from '@/services/message';
import { AI_RUNTIME_OPERATION_TYPES } from '@/store/chat/slices/operation';
@ -185,16 +182,6 @@ export class PluginTypesActionImpl {
};
};
invokeDefaultTypePlugin = async (id: string, payload: any): Promise<string | undefined> => {
const { internal_callPluginApi } = this.#get();
const data = await internal_callPluginApi(id, payload);
if (!data) return;
return data;
};
invokeKlavisTypePlugin = async (
id: string,
payload: ChatToolPayload,
@ -219,45 +206,6 @@ export class PluginTypesActionImpl {
);
};
invokeMarkdownTypePlugin = async (id: string, payload: ChatToolPayload): Promise<void> => {
const { internal_callPluginApi } = this.#get();
await internal_callPluginApi(id, payload);
};
invokeStandaloneTypePlugin = async (id: string, payload: ChatToolPayload): Promise<void> => {
const result = await useToolStore.getState().validatePluginSettings(payload.identifier);
if (!result) return;
// if the plugin settings is not valid, then set the message with error type
if (!result.valid) {
// Get message to extract agentId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(this.#get());
const updateResult = await messageService.updateMessageError(
id,
{
body: {
error: result.errors,
message: '[plugin] your settings is invalid with plugin manifest setting schema',
},
message: t('response.PluginSettingsInvalid', { ns: 'error' }),
type: PluginErrorType.PluginSettingsInvalid as any,
},
{
agentId: message?.agentId,
topicId: message?.topicId,
},
);
if (updateResult?.success && updateResult.messages) {
this.#get().replaceMessages(updateResult.messages, {
context: { agentId: message?.agentId || '', topicId: message?.topicId },
});
}
return;
}
};
invokeMCPTypePlugin = async (
id: string,
payload: ChatToolPayload,
@ -402,76 +350,6 @@ export class PluginTypesActionImpl {
return remoteContent;
};
internal_callPluginApi = async (
id: string,
payload: ChatToolPayload,
): Promise<string | undefined> => {
const { optimisticUpdateMessageContent } = this.#get();
let data: string;
// Get message to extract agentId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(this.#get());
// Get abort controller from operation
const operationId = this.#get().messageOperationMap[id];
const operation = operationId ? this.#get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[internal_callPluginApi] messageId=%s, plugin=%s, operationId=%s, aborted=%s',
id,
payload.identifier,
operationId,
abortController?.signal.aborted,
);
try {
const res = await chatService.runPluginApi(payload, {
signal: abortController?.signal,
trace: { observationId: message?.observationId, traceId: message?.traceId },
});
data = res.text;
// save traceId
if (res.traceId) {
await messageService.updateMessage(id, { traceId: res.traceId });
}
} catch (error) {
console.error(error);
const err = error as Error;
// ignore the aborted request error
if (err.message.includes('The user aborted a request.')) {
log(
'[internal_callPluginApi] Request aborted: messageId=%s, plugin=%s',
id,
payload.identifier,
);
} else {
const result = await messageService.updateMessageError(id, error as any, {
agentId: message?.agentId,
topicId: message?.topicId,
});
if (result?.success && result.messages) {
this.#get().replaceMessages(result.messages, {
context: { agentId: message?.agentId || '', topicId: message?.topicId },
});
}
}
data = '';
}
// If error occurred, exit
if (!data) return;
// operationId already declared above, reuse it
const context = operationId ? { operationId } : undefined;
await optimisticUpdateMessageContent(id, data, undefined, context);
return data;
};
}
export type PluginTypesAction = Pick<PluginTypesActionImpl, keyof PluginTypesActionImpl>;

View file

@ -92,26 +92,15 @@ export class PluginPublicApiActionImpl {
stepContext?: RuntimeStepContext,
): Promise<any> => {
switch (payload.type) {
case 'standalone': {
return await this.#get().invokeStandaloneTypePlugin(id, payload);
}
case 'markdown': {
return await this.#get().invokeMarkdownTypePlugin(id, payload);
}
case 'builtin': {
// Pass stepContext to builtin tools for dynamic state access
return await this.#get().invokeBuiltinTool(id, payload, stepContext);
}
// @ts-ignore
case 'mcp': {
return await this.#get().invokeMCPTypePlugin(id, payload);
}
case 'builtin':
default: {
return await this.#get().invokeDefaultTypePlugin(id, payload);
// Pass stepContext to builtin tools for dynamic state access
return await this.#get().invokeBuiltinTool(id, payload, stepContext);
}
}
};

View file

@ -1,5 +1,4 @@
import { type LobeTool } from '@lobechat/types';
import { type PluginSchema } from '@lobehub/chat-plugin-sdk';
import { type LobeTool, type ToolManifestSettings } from '@lobechat/types';
import { type MetaData } from '@/types/meta';
@ -14,7 +13,7 @@ const getPluginAvatar = (meta?: MetaData) => meta?.avatar || '🧩';
const isCustomPlugin = (id: string, pluginList: LobeTool[]) =>
pluginList.some((i) => i.identifier === id && i.type === 'customPlugin');
const isSettingSchemaNonEmpty = (schema?: PluginSchema) =>
const isSettingSchemaNonEmpty = (schema?: ToolManifestSettings) =>
schema?.properties && Object.keys(schema.properties).length > 0;
export const pluginHelpers = {

View file

@ -10,12 +10,10 @@ import {
type LobehubSkillStoreState,
} from './slices/lobehubSkillStore/initialState';
import { initialMCPStoreState, type MCPStoreState } from './slices/mcpStore/initialState';
import { initialPluginStoreState, type PluginStoreState } from './slices/oldStore/initialState';
import { initialPluginState, type PluginState } from './slices/plugin/initialState';
export type ToolStoreState = PluginState &
CustomPluginState &
PluginStoreState &
BuiltinToolState &
MCPStoreState &
KlavisStoreState &
@ -25,7 +23,6 @@ export type ToolStoreState = PluginState &
export const initialState: ToolStoreState = {
...initialPluginState,
...initialCustomPluginState,
...initialPluginStoreState,
...initialBuiltinToolState,
...initialMCPStoreState,
...initialKlavisStoreState,

View file

@ -7,6 +7,5 @@ export { customPluginSelectors } from '../slices/customPlugin/selectors';
export { klavisStoreSelectors } from '../slices/klavisStore/selectors';
export { lobehubSkillStoreSelectors } from '../slices/lobehubSkillStore/selectors';
export { mcpStoreSelectors } from '../slices/mcpStore/selectors';
export { pluginStoreSelectors } from '../slices/oldStore/selectors';
export { pluginSelectors } from '../slices/plugin/selectors';
export { toolSelectors } from './tool';

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { type ToolStoreState } from '../initialState';
@ -28,7 +28,7 @@ const mockState = {
createdAt: '2024-01-01',
homepage: 'https://example.com/plugin-1',
meta: { title: 'Plugin 1', description: 'Plugin 1 description' },
} as LobeChatPluginManifest,
} as ToolManifest,
runtimeType: 'standalone',
type: 'plugin',
},
@ -39,7 +39,7 @@ const mockState = {
api: [{ name: 'api-2' }],
author: 'Another Author',
homepage: 'https://example.com/plugin-2',
} as LobeChatPluginManifest,
} as ToolManifest,
runtimeType: 'default',
type: 'plugin',
},
@ -67,7 +67,7 @@ const mockState = {
identifier: 'builtin-1',
api: [{ name: 'builtin-api-1' }],
meta: { title: 'Builtin 1', description: 'Builtin 1 description' },
} as LobeChatPluginManifest,
} as ToolManifest,
},
],
pluginInstallLoading: {

View file

@ -1,6 +1,5 @@
import { getKlavisServerByServerIdentifier, getLobehubSkillProviderById } from '@lobechat/const';
import { type RenderDisplayControl } from '@lobechat/types';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type RenderDisplayControl, type ToolManifest } from '@lobechat/types';
import {
isInstalledPluginAvailableInCurrentEnv,
@ -42,10 +41,10 @@ const getMetaById =
const getManifestById =
(id: string) =>
(s: ToolStoreState): LobeChatPluginManifest | undefined =>
(s: ToolStoreState): ToolManifest | undefined =>
pluginSelectors
.installedPluginManifestList(s)
.concat(s.builtinTools.map((b) => b.manifest as LobeChatPluginManifest))
.concat(s.builtinTools.map((b) => b.manifest as ToolManifest))
.find((i) => i.identifier === id);
// Get plugin manifest loading status

View file

@ -16,6 +16,7 @@ vi.mock('@/services/plugin', () => ({
createCustomPlugin: vi.fn(),
uninstallPlugin: vi.fn(),
updatePluginManifest: vi.fn(),
getInstalledPlugins: vi.fn().mockResolvedValue([]),
},
}));

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { merge } from 'es-toolkit/compat';
import { t } from 'i18next';
@ -46,7 +46,7 @@ export class CustomPluginActionImpl {
try {
updateInstallLoadingState(id, true);
let manifest: LobeChatPluginManifest;
let manifest: ToolManifest;
// mean this is a mcp plugin
if (!!plugin.customParams?.mcp) {
const url = plugin.customParams?.mcp?.url;

View file

@ -1,4 +1,4 @@
import { type LobeChatPluginManifest, type LobeChatPluginMeta } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { type ToolStoreState } from '../../initialState';
@ -19,7 +19,7 @@ const mockState = {
identifier: 'plugin-1',
api: [{ name: 'api-1' }],
type: 'default',
} as LobeChatPluginManifest,
} as ToolManifest,
},
{
identifier: 'plugin-2',
@ -38,7 +38,7 @@ const mockState = {
createdAt: '2021-01-01',
meta: { avatar: 'avatar-url-1', title: 'Plugin 1' },
homepage: 'http://homepage-1.com',
} as LobeChatPluginMeta,
} as any,
{
identifier: 'plugin-2',
author: 'Author 2',

View file

@ -1,5 +1,5 @@
import type * as LobechatConstModule from '@lobechat/const';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { type PluginItem } from '@lobehub/market-sdk';
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -230,7 +230,7 @@ describe('mcpStore actions', () => {
});
describe('testMcpConnection', () => {
const mockManifest: LobeChatPluginManifest = {
const mockManifest: ToolManifest = {
api: [],
gateway: '',
identifier: 'test-plugin',
@ -717,7 +717,7 @@ describe('mcpStore actions', () => {
},
};
const mockServerManifest: LobeChatPluginManifest = {
const mockServerManifest: ToolManifest = {
api: [],
gateway: '',
identifier: 'test-plugin',
@ -1170,7 +1170,7 @@ describe('mcpStore actions', () => {
version: '1.5.0',
};
const serverManifestWithVersion: LobeChatPluginManifest = {
const serverManifestWithVersion: ToolManifest = {
api: [],
gateway: '',
identifier: 'test-plugin',

View file

@ -1,5 +1,5 @@
import { CURRENT_VERSION, isDesktop } from '@lobechat/const';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type ToolManifest } from '@lobechat/types';
import { type PluginItem, type PluginListResponse } from '@lobehub/market-sdk';
import { type TRPCClientError } from '@trpc/client';
import debug from 'debug';
@ -73,7 +73,7 @@ const toNonEmptyStringRecord = (input?: Record<string, any>) => {
const buildCloudMcpManifest = (params: {
data: any;
plugin: { description?: string; icon?: string; identifier: string };
}): LobeChatPluginManifest => {
}): ToolManifest => {
const { data, plugin } = params;
log('Using cloud connection, building manifest from market data');
@ -104,7 +104,7 @@ const buildCloudMcpManifest = (params: {
}
// Build complete manifest
const manifest: LobeChatPluginManifest = {
const manifest: ToolManifest = {
api: apiArray,
author: data.author?.name || data.author || '',
createAt: data.createdAt || new Date().toISOString(),
@ -120,7 +120,7 @@ const buildCloudMcpManifest = (params: {
name: data.name || plugin.identifier,
type: 'mcp',
version: data.version,
} as unknown as LobeChatPluginManifest;
} as unknown as ToolManifest;
log('[Cloud MCP] Final manifest built:', {
apiCount: manifest.api?.length,
@ -136,7 +136,7 @@ export interface TestMcpConnectionResult {
error?: string;
/** STDIO process output logs for debugging */
errorLog?: string;
manifest?: LobeChatPluginManifest;
manifest?: ToolManifest;
success: boolean;
}
@ -470,7 +470,7 @@ export class PluginMCPStoreActionImpl {
return;
}
let manifest: LobeChatPluginManifest | undefined;
let manifest: ToolManifest | undefined;
if (connection?.type === 'stdio') {
manifest = await mcpService.getStdioMcpServerManifest(
@ -750,7 +750,7 @@ export class PluginMCPStoreActionImpl {
);
try {
let manifest: LobeChatPluginManifest;
let manifest: ToolManifest;
if (connection.type === 'http') {
if (!connection.url) {

View file

@ -2,12 +2,15 @@ import { type PluginItem } from '@lobehub/market-sdk';
import { type MCPInstallProgressMap } from '@/types/plugins';
export type PluginStoreListType = 'installed' | 'mcp';
export interface MCPStoreState {
activeMCPIdentifier?: string;
categories: string[];
currentPage: number;
isLoadingMore?: boolean;
isMcpListInit?: boolean;
listType: PluginStoreListType;
mcpInstallAbortControllers: Record<string, AbortController>;
mcpInstallProgress: MCPInstallProgressMap;
mcpPluginItems: PluginItem[];
@ -25,6 +28,7 @@ export interface MCPStoreState {
export const initialMCPStoreState: MCPStoreState = {
categories: [],
currentPage: 1,
listType: 'mcp',
mcpInstallAbortControllers: {},
mcpInstallProgress: {},
mcpPluginItems: [],

View file

@ -5,7 +5,6 @@ import { MCPInstallStep } from '@/types/plugins';
import { initialState } from '../../initialState';
import { type ToolStoreState } from '../../initialState';
import { PluginStoreTabs } from '../oldStore/initialState';
import { mcpStoreSelectors } from './selectors';
const createMockPluginItem = (id: string, overrides: Partial<PluginItem> = {}): PluginItem =>
@ -56,20 +55,20 @@ const baseState: ToolStoreState = {
settings: {},
},
],
listType: PluginStoreTabs.MCP,
listType: 'mcp',
};
describe('mcpStoreSelectors', () => {
describe('mcpPluginList', () => {
it('should return all mcp plugins when listType is MCP', () => {
const state: ToolStoreState = { ...baseState, listType: PluginStoreTabs.MCP };
const state: ToolStoreState = { ...baseState, listType: 'mcp' };
const result = mcpStoreSelectors.mcpPluginList(state);
expect(result).toHaveLength(3);
});
it('should return only installed plugins when listType is Installed', () => {
const state: ToolStoreState = { ...baseState, listType: PluginStoreTabs.Installed };
const state: ToolStoreState = { ...baseState, listType: 'installed' };
const result = mcpStoreSelectors.mcpPluginList(state);
expect(result).toHaveLength(1);
@ -77,7 +76,7 @@ describe('mcpStoreSelectors', () => {
});
it('should map plugin items to InstallPluginMeta format', () => {
const state: ToolStoreState = { ...baseState, listType: PluginStoreTabs.MCP };
const state: ToolStoreState = { ...baseState, listType: 'mcp' };
const result = mcpStoreSelectors.mcpPluginList(state);
const item = result[0];
@ -99,7 +98,7 @@ describe('mcpStoreSelectors', () => {
const state: ToolStoreState = {
...baseState,
installedPlugins: [],
listType: PluginStoreTabs.Installed,
listType: 'installed',
};
const result = mcpStoreSelectors.mcpPluginList(state);
@ -130,7 +129,7 @@ describe('mcpStoreSelectors', () => {
settings: {},
},
],
listType: PluginStoreTabs.Installed,
listType: 'installed',
};
const result = mcpStoreSelectors.mcpPluginList(state);

View file

@ -1,249 +0,0 @@
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { act, renderHook, waitFor } from '@testing-library/react';
import { type Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { notification } from '@/components/AntdStaticMethods';
import { pluginService } from '@/services/plugin';
import { toolService } from '@/services/tool';
import { type DiscoverPluginItem } from '@/types/discover';
import { useToolStore } from '../../store';
// Mock necessary modules and functions
vi.mock('@/components/AntdStaticMethods', () => ({
notification: {
error: vi.fn(),
},
}));
// Mock the pluginService.getToolList method
vi.mock('@/services/plugin', () => ({
pluginService: {
uninstallPlugin: vi.fn(),
installPlugin: vi.fn(),
},
}));
vi.mock('@/services/tool', () => ({
toolService: {
getToolManifest: vi.fn(),
getToolList: vi.fn(),
getOldPluginList: vi.fn(),
},
}));
// Mock i18next
vi.mock('i18next', () => ({
t: vi.fn((key) => key),
}));
const pluginManifestMock = {
$schema: '../node_modules/@lobehub/chat-plugin-sdk/schema.json',
api: [
{
url: 'https://realtime-weather.chat-plugin.lobehub.com/api/v1',
name: 'fetchCurrentWeather',
description: '获取当前天气情况',
parameters: {
properties: {
city: {
description: '城市名称',
type: 'string',
},
},
required: ['city'],
type: 'object',
},
},
],
author: 'LobeHub',
createAt: '2023-08-12',
homepage: 'https://github.com/lobehub/chat-plugin-realtime-weather',
identifier: 'realtime-weather',
meta: {
avatar: '🌈',
tags: ['weather', 'realtime'],
title: 'Realtime Weather',
description: 'Get realtime weather information',
},
ui: {
url: 'https://realtime-weather.chat-plugin.lobehub.com/iframe',
height: 310,
},
version: '1',
};
const logError = console.error;
beforeEach(() => {
vi.restoreAllMocks();
useToolStore.setState({
oldPluginItems: [
{
identifier: 'plugin1',
title: 'plugin1',
avatar: '🍏',
manifest: 'https://abc.com/manifest.json',
} as DiscoverPluginItem,
],
});
console.error = () => {};
});
afterEach(() => {
console.error = logError;
});
describe('useToolStore:pluginStore', () => {
describe('loadPluginStore', () => {
it('should load plugin list and update state', async () => {
// Given
const pluginListMock = [{ identifier: 'plugin1' }, { identifier: 'plugin2' }];
(toolService.getOldPluginList as Mock).mockResolvedValue({ items: pluginListMock });
// When
let pluginList;
await act(async () => {
pluginList = await useToolStore.getState().loadPluginStore();
});
// Then
expect(toolService.getOldPluginList).toHaveBeenCalled();
expect(pluginList).toEqual(pluginListMock);
expect(useToolStore.getState().oldPluginItems).toEqual(pluginListMock);
});
it('should handle errors when loading plugin list', async () => {
// Given
const error = new Error('Failed to load plugin list');
(toolService.getOldPluginList as Mock).mockRejectedValue(error);
// When
let pluginList;
let errorOccurred = false;
try {
await act(async () => {
pluginList = await useToolStore.getState().loadPluginStore();
});
} catch (e) {
errorOccurred = true;
}
// Then
expect(toolService.getOldPluginList).toHaveBeenCalled();
expect(errorOccurred).toBe(true);
expect(pluginList).toBeUndefined();
// Ensure the state is not updated with an undefined value
expect(useToolStore.getState().oldPluginItems).not.toBeUndefined();
});
});
describe('useFetchPluginStore', () => {
it('should fetch plugin store data', async () => {
// Given
const pluginListMock = [{ identifier: 'plugin1' }, { identifier: 'plugin2' }];
(toolService.getOldPluginList as Mock).mockResolvedValue({ items: pluginListMock });
// When
const { result } = renderHook(() => useToolStore().useFetchPluginStore());
// Wait for SWR to fetch data
await waitFor(() => {
expect(result.current.data).toEqual(pluginListMock);
});
// Then
expect(toolService.getOldPluginList).toHaveBeenCalled();
expect(result.current.error).toBeUndefined();
});
// Note: Error handling test is not included because SWR retries by default,
// making error scenarios difficult to test in unit tests.
// The underlying loadPluginStore error handling is tested separately above.
});
describe('installPlugin', () => {
it('should be deprecated and do nothing', async () => {
// Old plugin system has been deprecated
await act(async () => {
await useToolStore.getState().installPlugin('plugin1');
});
// Should not call any service
expect(toolService.getToolManifest).not.toHaveBeenCalled();
expect(pluginService.installPlugin).not.toHaveBeenCalled();
expect(notification.error).not.toHaveBeenCalled();
});
});
describe('installPlugins', () => {
it('should be deprecated and do nothing', async () => {
// Old plugin system has been deprecated
await act(async () => {
await useToolStore.getState().installPlugins(['plugin1', 'plugin2']);
});
// Should not call any service
expect(pluginService.installPlugin).not.toHaveBeenCalled();
});
});
describe('unInstallPlugin', () => {
it('should uninstall a plugin and remove its manifest', async () => {
// Given
const pluginIdentifier = 'plugin1';
act(() => {
useToolStore.setState({
installedPlugins: [
{
identifier: pluginIdentifier,
type: 'plugin',
manifest: {
identifier: pluginIdentifier,
} as LobeChatPluginManifest,
},
],
});
});
// When
act(() => {
useToolStore.getState().uninstallPlugin(pluginIdentifier);
});
// Then
expect(pluginService.uninstallPlugin).toBeCalledWith(pluginIdentifier);
});
});
describe('updateInstallLoadingState', () => {
it('should update the loading state for a plugin', () => {
const pluginIdentifier = 'abc';
const loadingState = true;
const { result } = renderHook(() => useToolStore());
act(() => {
result.current.updateInstallLoadingState(pluginIdentifier, loadingState);
});
expect(result.current.pluginInstallLoading[pluginIdentifier]).toBe(loadingState);
});
it('should clear the loading state for a plugin', () => {
// Given
const pluginIdentifier = 'dddd';
const loadingState = undefined;
act(() => {
useToolStore.setState({ pluginInstallLoading: { [pluginIdentifier]: true } });
});
const { result } = renderHook(() => useToolStore());
// When
act(() => {
result.current.updateInstallLoadingState(pluginIdentifier, loadingState);
});
// Then
expect(result.current.pluginInstallLoading[pluginIdentifier]).toBe(loadingState);
});
});
});

View file

@ -1,197 +0,0 @@
import { type LobeTool } from '@lobechat/types';
import { uniqBy } from 'es-toolkit/compat';
import { produce } from 'immer';
import { type SWRResponse } from 'swr';
import useSWR from 'swr';
import { mutate } from '@/libs/swr';
import { pluginService } from '@/services/plugin';
import { toolService } from '@/services/tool';
import { globalHelpers } from '@/store/global/helpers';
import { type StoreSetter } from '@/store/types';
import {
type DiscoverPluginItem,
type PluginListResponse,
type PluginQueryParams,
} from '@/types/discover';
import { setNamespace } from '@/utils/storeDebug';
import { type ToolStore } from '../../store';
import { type PluginInstallProgress, type PluginStoreState } from './initialState';
const n = setNamespace('pluginStore');
const INSTALLED_PLUGINS = 'loadInstalledPlugins';
type Setter = StoreSetter<ToolStore>;
export const createPluginStoreSlice = (set: Setter, get: () => ToolStore, _api?: unknown) =>
new PluginStoreActionImpl(set, get, _api);
export class PluginStoreActionImpl {
readonly #get: () => ToolStore;
readonly #set: Setter;
constructor(set: Setter, get: () => ToolStore, _api?: unknown) {
void _api;
this.#set = set;
this.#get = get;
}
installOldPlugin = async (
_name: string,
_type: 'plugin' | 'customPlugin' = 'plugin',
): Promise<void> => {
// Old plugin system has been deprecated, skip installation silently
};
installPlugin = async (
_name: string,
_type: 'plugin' | 'customPlugin' = 'plugin',
): Promise<void> => {
// Old plugin system has been deprecated, skip installation silently
};
installPlugins = async (_plugins: string[]): Promise<void> => {
// Old plugin system has been deprecated, skip installation silently
};
loadMorePlugins = (): void => {
const { oldPluginItems, pluginTotalCount, currentPluginPage } = this.#get();
// Check if there is more data to load
if (oldPluginItems.length < (pluginTotalCount || 0)) {
this.#set(
produce((draft: PluginStoreState) => {
draft.currentPluginPage = currentPluginPage + 1;
}),
false,
n('loadMorePlugins'),
);
}
};
loadPluginStore = async (): Promise<DiscoverPluginItem[]> => {
const locale = globalHelpers.getCurrentLanguage();
const data = await toolService.getOldPluginList({
locale,
page: 1,
pageSize: 50,
});
this.#set({ oldPluginItems: data.items }, false, n('loadPluginList'));
return data.items;
};
refreshPlugins = async (): Promise<void> => {
await mutate(INSTALLED_PLUGINS);
};
resetPluginList = (keywords?: string): void => {
this.#set(
produce((draft: PluginStoreState) => {
draft.oldPluginItems = [];
draft.currentPluginPage = 1;
draft.pluginSearchKeywords = keywords;
}),
false,
n('resetPluginList'),
);
};
uninstallPlugin = async (identifier: string): Promise<void> => {
await pluginService.uninstallPlugin(identifier);
await this.#get().refreshPlugins();
};
updateInstallLoadingState = (key: string, value: boolean | undefined): void => {
this.#set(
produce((draft: PluginStoreState) => {
draft.pluginInstallLoading[key] = value;
}),
false,
n('updateInstallLoadingState'),
);
};
updatePluginInstallProgress = (
identifier: string,
progress: PluginInstallProgress | undefined,
): void => {
this.#set(
produce((draft: PluginStoreState) => {
draft.pluginInstallProgress[identifier] = progress;
}),
false,
n(`updatePluginInstallProgress/${progress?.step || 'clear'}`),
);
};
useFetchInstalledPlugins = (enabled: boolean): SWRResponse<LobeTool[]> => {
return useSWR<LobeTool[]>(
enabled ? INSTALLED_PLUGINS : null,
pluginService.getInstalledPlugins,
{
fallbackData: [],
onSuccess: (data) => {
this.#set(
{ installedPlugins: data, loadingInstallPlugins: false },
false,
n('useFetchInstalledPlugins'),
);
},
revalidateOnFocus: false,
suspense: true,
},
);
};
useFetchPluginList = (params: PluginQueryParams): SWRResponse<PluginListResponse> => {
const locale = globalHelpers.getCurrentLanguage();
return useSWR<PluginListResponse>(
['useFetchPluginList', locale, ...Object.values(params)].filter(Boolean).join('-'),
async () => toolService.getOldPluginList(params),
{
onSuccess: (data) => {
this.#set(
produce((draft: PluginStoreState) => {
draft.pluginSearchLoading = false;
// Set basic information
if (!draft.isPluginListInit) {
draft.activePluginIdentifier = data.items?.[0]?.identifier;
draft.isPluginListInit = true;
draft.pluginTotalCount = data.totalCount;
}
// Accumulate data logic
if (params.page === 1) {
// First page, set directly
draft.oldPluginItems = uniqBy(data.items, 'identifier');
} else {
// Subsequent pages, accumulate data
draft.oldPluginItems = uniqBy(
[...draft.oldPluginItems, ...data.items],
'identifier',
);
}
}),
false,
n('useFetchPluginList/onSuccess'),
);
},
revalidateOnFocus: false,
},
);
};
useFetchPluginStore = (): SWRResponse<DiscoverPluginItem[]> => {
return useSWR<DiscoverPluginItem[]>('loadPluginStore', this.#get().loadPluginStore, {
revalidateOnFocus: false,
});
};
}
export type PluginStoreAction = Pick<PluginStoreActionImpl, keyof PluginStoreActionImpl>;

View file

@ -1,2 +0,0 @@
export * from './action';
export * from './initialState';

View file

@ -1,51 +0,0 @@
import { type DiscoverPluginItem } from '@/types/discover';
export type PluginInstallLoadingMap = Record<string, boolean | undefined>;
export enum PluginStoreTabs {
Installed = 'installed',
MCP = 'mcp',
Plugin = 'old',
}
export enum PluginInstallStep {
COMPLETED = 'COMPLETED',
ERROR = 'ERROR',
FETCHING_MANIFEST = 'FETCHING_MANIFEST',
INSTALLING_PLUGIN = 'INSTALLING_PLUGIN',
}
export interface PluginInstallProgress {
// Error message
error?: string;
// 0-100
progress: number;
step: PluginInstallStep;
}
export type PluginInstallProgressMap = Record<string, PluginInstallProgress | undefined>;
export interface PluginStoreState {
activePluginIdentifier?: string;
currentPluginPage: number;
displayMode: 'grid' | 'list';
isPluginListInit?: boolean;
listType: PluginStoreTabs;
oldPluginItems: DiscoverPluginItem[];
pluginInstallLoading: PluginInstallLoadingMap;
pluginInstallProgress: PluginInstallProgressMap;
pluginSearchKeywords?: string;
pluginSearchLoading?: boolean;
pluginTotalCount?: number;
}
export const initialPluginStoreState: PluginStoreState = {
// Plugin list state management initial values
currentPluginPage: 1,
displayMode: 'grid',
listType: PluginStoreTabs.MCP,
oldPluginItems: [],
pluginInstallLoading: {},
pluginInstallProgress: {},
};

View file

@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest';
import { type ToolStoreState } from '../../initialState';
import { initialState } from '../../initialState';
import { pluginStoreSelectors } from './selectors';
const mockState = {
...initialState,
listType: 'old',
oldPluginItems: [
{
identifier: 'plugin-1',
author: 'Author 1',
createdAt: '2021-01-01',
avatar: 'avatar-url-1',
title: 'Plugin 1',
homepage: 'http://homepage-1.com',
},
{
identifier: 'plugin-2',
author: 'Author 2',
createdAt: '2022-02-02',
avatar: 'avatar-url-2',
title: 'Plugin 2',
homepage: 'http://homepage-2.com',
},
],
} as ToolStoreState;
describe('pluginStoreSelectors', () => {
describe('onlinePluginStore', () => {
it('should return the online plugin list', () => {
const result = pluginStoreSelectors.onlinePluginStore(mockState);
expect(result).toEqual([
{
identifier: 'plugin-1',
author: 'Author 1',
createdAt: '2021-01-01',
meta: { avatar: 'avatar-url-1', title: 'Plugin 1' },
homepage: 'http://homepage-1.com',
type: 'plugin',
},
{
identifier: 'plugin-2',
author: 'Author 2',
createdAt: '2022-02-02',
meta: { avatar: 'avatar-url-2', title: 'Plugin 2' },
homepage: 'http://homepage-2.com',
type: 'plugin',
},
]);
});
});
});

View file

@ -1,45 +0,0 @@
import { PluginStoreTabs } from '@/store/tool/slices/oldStore/initialState';
import { type InstallPluginMeta } from '@/types/tool/plugin';
import { type ToolStoreState } from '../../initialState';
const onlinePluginStore = (s: ToolStoreState) => {
const installedPluginIds = new Set(s.installedPlugins.map((i) => i.identifier));
const list =
s.listType === PluginStoreTabs.Plugin
? s.oldPluginItems
: s.oldPluginItems.filter((p) => installedPluginIds.has(p.identifier));
return list.map<InstallPluginMeta>((p) => ({
author: p.author,
createdAt: p.createdAt,
homepage: p.homepage,
identifier: p.identifier,
meta: {
avatar: p.avatar,
description: p.description,
tags: p.tags,
title: p.title,
},
type: 'plugin',
}));
};
const isPluginInstallLoading = (id: string) => (s: ToolStoreState) => s.pluginInstallLoading[id];
const getPluginInstallProgress = (id: string) => (s: ToolStoreState) => s.pluginInstallProgress[id];
const isOldPluginInInstallProgress = (id: string) => (s: ToolStoreState) =>
!!s.pluginInstallProgress[id];
const getPluginById = (id: string) => (s: ToolStoreState) => {
return s.oldPluginItems.find((i) => i.identifier === id);
};
export const pluginStoreSelectors = {
getPluginById,
getPluginInstallProgress,
isOldPluginInInstallProgress,
isPluginInstallLoading,
onlinePluginStore,
};

View file

@ -9,6 +9,7 @@ import { useToolStore } from '../../store';
vi.mock('@/services/plugin', () => ({
pluginService: {
getInstalledPlugins: vi.fn().mockResolvedValue([]),
updatePluginSettings: vi.fn(),
removeAllPlugins: vi.fn(),
},
@ -23,13 +24,6 @@ describe('useToolStore:plugin', () => {
describe('checkPluginsIsInstalled', () => {
it('should be deprecated and do nothing', async () => {
// Old plugin system has been deprecated
const loadPluginStoreMock = vi.fn();
const installPluginsMock = vi.fn();
useToolStore.setState({
loadPluginStore: loadPluginStoreMock,
installPlugins: installPluginsMock,
});
const { result } = renderHook(() => useToolStore());
await act(async () => {
@ -37,8 +31,6 @@ describe('useToolStore:plugin', () => {
});
// Should not call any methods since old plugin system is deprecated
expect(loadPluginStoreMock).not.toHaveBeenCalled();
expect(installPluginsMock).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show more