This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 3c63c9fe feat: add AI tool catalog and runner (#736)
3c63c9fe is described below
commit 3c63c9fe785dc684bb9117278f94af56afb241c9
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:16:28 2026 +0800
feat: add AI tool catalog and runner (#736)
---
web/src/api/ai.test.ts | 31 +++-
web/src/api/ai.ts | 21 ++-
web/src/pages/ai/__tests__/AiPage.test.tsx | 184 +++++++++++++++++++++
web/src/pages/ai/index.tsx | 247 ++++++++++++++++++++++++++++-
4 files changed, 470 insertions(+), 13 deletions(-)
diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index b175df54..b01a7cb9 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -22,6 +22,7 @@ import {
AiStreamError,
chatStream,
executeAiCommand,
+ executeTool,
listTools,
type AiExecuteRequest,
type McpTool,
@@ -118,12 +119,14 @@ describe('AI API', () => {
it('throws structured errors from SSE error events', async () => {
vi.stubGlobal(
'fetch',
- vi.fn().mockResolvedValue(
- streamResponse([
- 'event: error\n',
- 'data: {"status":400,"code":"llm.config.incomplete","message":"LLM
provider is not configured or enabled","hint":"Configure and enable an LLM
provider."}\n\n',
- ]),
- ),
+ vi
+ .fn()
+ .mockResolvedValue(
+ streamResponse([
+ 'event: error\n',
+ 'data:
{"status":400,"code":"llm.config.incomplete","message":"LLM provider is not
configured or enabled","hint":"Configure and enable an LLM provider."}\n\n',
+ ]),
+ ),
);
await expect(
@@ -195,9 +198,25 @@ describe('AI API', () => {
expect(result).toEqual([]);
});
+ it('scopes tool discovery to the selected cluster', async () => {
+ mock.onGet('/ai/tools', { params: { cluster: 'cluster-a' } }).reply(200,
{ data: [] });
+
+ await expect(listTools('cluster-a')).resolves.toEqual([]);
+ });
+
it('should handle server error', async () => {
mock.onGet('/ai/tools').reply(500);
await expect(listTools()).rejects.toThrow();
});
});
+
+ describe('executeTool', () => {
+ it('posts structured input and returns structured output', async () => {
+ const input = { cluster: 'cluster-a', topic: 'orders' };
+ const output = { items: [{ name: 'orders' }], total: 1 };
+ mock.onPost('/ai/tools/rmq.topic.list/execute', input).reply(200, {
data: output });
+
+ await expect(executeTool('rmq.topic.list',
input)).resolves.toEqual(output);
+ });
+ });
});
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index fdc9d9e6..81f40d08 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -22,6 +22,13 @@ export interface McpTool {
name: string;
description: string;
parameters: Record<string, unknown>;
+ riskLevel?: string;
+ permission?: string;
+ requiredCapabilities?: string[];
+ outputSchema?: Record<string, unknown>;
+ viewHint?: string;
+ deprecated?: boolean;
+ replacement?: string;
}
export interface AiExecuteRequest {
@@ -172,7 +179,17 @@ export async function executeAiCommand(data:
AiExecuteRequest) {
return res.data.data;
}
-export async function listTools() {
- const res = await client.get<{ data: McpTool[] }>('/ai/tools');
+export async function listTools(cluster?: string) {
+ const res = await client.get<{ data: McpTool[] }>('/ai/tools', {
+ params: cluster ? { cluster } : undefined,
+ });
+ return res.data.data;
+}
+
+export async function executeTool(name: string, input: Record<string,
unknown>) {
+ const res = await client.post<{ data: unknown }>(
+ `/ai/tools/${encodeURIComponent(name)}/execute`,
+ input,
+ );
return res.data.data;
}
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
new file mode 100644
index 00000000..8f516f75
--- /dev/null
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { MemoryRouter } from 'react-router-dom';
+import { LangProvider } from '../../../i18n/LangContext';
+import { executeTool, listTools } from '../../../api/ai';
+import { listClusters, type ClusterInfo } from '../../../api/cluster';
+import { getLlmConfig, getLlmModels } from '../../../api/llm';
+import AiPage from '../index';
+
+vi.mock('../../../api/ai', () => ({
+ AiStreamError: class AiStreamError extends Error {},
+ chatStream: vi.fn(),
+ executeTool: vi.fn(),
+ listTools: vi.fn(),
+}));
+
+vi.mock('../../../api/llm', () => ({
+ getLlmConfig: vi.fn(),
+ getLlmModels: vi.fn(),
+}));
+
+vi.mock('../../../api/cluster', () => ({
+ listClusters: vi.fn(),
+}));
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+const renderPage = () =>
+ render(
+ <App>
+ <LangProvider>
+ <MemoryRouter initialEntries={['/ai']}>
+ <AiPage />
+ </MemoryRouter>
+ </LangProvider>
+ </App>,
+ );
+
+describe('AiPage tool runner', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getLlmConfig).mockResolvedValue({
+ provider: 'openai',
+ apiBase: 'https://api.openai.com/v1',
+ model: 'gpt-4o',
+ maxTokens: 1024,
+ temperature: 0.2,
+ enabled: true,
+ ready: true,
+ });
+ vi.mocked(getLlmModels).mockResolvedValue({
+ status: 0,
+ data: [{ id: 'gpt-4o' }],
+ });
+ vi.mocked(listClusters).mockResolvedValue([
+ { id: 'cluster-a', name: 'Cluster A' } as ClusterInfo,
+ { id: 'cluster-b', name: 'Cluster B' } as ClusterInfo,
+ ]);
+ vi.mocked(listTools).mockResolvedValue([
+ {
+ name: 'rmq.capabilities',
+ description: 'Describe cluster capabilities.',
+ parameters: {
+ type: 'object',
+ required: ['cluster'],
+ properties: { cluster: { type: 'string' } },
+ },
+ riskLevel: 'L1',
+ permission: 'cluster:read',
+ },
+ ]);
+ });
+
+ it('loads the catalog, creates a schema template, and renders structured
output', async () => {
+ const user = userEvent.setup();
+ vi.mocked(executeTool).mockResolvedValue({
+ cluster: 'cluster-a',
+ capabilities: ['GRPC'],
+ });
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: '工具' }));
+
+ const dialog = await screen.findByRole('dialog', { name: 'AI 工具' });
+ await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-a'));
+ expect(within(dialog).getByText('Cluster A')).toBeInTheDocument();
+ expect(within(dialog).getByText('rmq.capabilities')).toBeInTheDocument();
+ expect(within(dialog).getByText('L1')).toBeInTheDocument();
+ expect(within(dialog).getByText('cluster:read')).toBeInTheDocument();
+
+ const input = within(dialog).getByRole('textbox', { name: '工具参数 JSON' });
+ expect(input).toHaveValue('{\n "cluster": "cluster-a"\n}');
+ fireEvent.change(input, { target: { value: '{"cluster":"cluster-a"}' } });
+ await user.click(within(dialog).getByRole('button', { name: /执\s*行/ }));
+
+ await waitFor(() => {
+ expect(executeTool).toHaveBeenCalledWith('rmq.capabilities', {
+ cluster: 'cluster-a',
+ });
+ });
+ expect(await
within(dialog).findByTestId('tool-result')).toHaveTextContent('"capabilities":
[');
+
expect(within(dialog).getByTestId('tool-result')).toHaveTextContent('"GRPC"');
+ });
+
+ it('reloads the available tools and template when the cluster changes',
async () => {
+ const user = userEvent.setup();
+ vi.mocked(listTools).mockImplementation(async (cluster) => [
+ {
+ name: `rmq.tool.${cluster}`,
+ description: `Tool for ${cluster}`,
+ parameters: {
+ type: 'object',
+ required: ['cluster'],
+ properties: { cluster: { type: 'string' } },
+ },
+ },
+ ]);
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: '工具' }));
+ const dialog = await screen.findByRole('dialog', { name: 'AI 工具' });
+ await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-a'));
+
+ const clusterSelect = within(dialog).getByRole('combobox', { name: '选择集群'
});
+ await user.click(clusterSelect);
+ await user.click(
+ await screen.findByText('Cluster B', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-b'));
+ expect(within(dialog).getByText('rmq.tool.cluster-b')).toBeInTheDocument();
+ expect(within(dialog).getByRole('textbox', { name: '工具参数 JSON'
})).toHaveValue(
+ '{\n "cluster": "cluster-b"\n}',
+ );
+ });
+
+ it('rejects input that is not a JSON object', async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: '工具' }));
+ const dialog = await screen.findByRole('dialog', { name: 'AI 工具' });
+ const input = await within(dialog).findByRole('textbox', { name: '工具参数
JSON' });
+ fireEvent.change(input, { target: { value: '[]' } });
+ await user.click(within(dialog).getByRole('button', { name: /执\s*行/ }));
+
+ expect(await screen.findByText('工具参数必须是有效的 JSON 对象')).toBeInTheDocument();
+ expect(executeTool).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index ee042921..c0463ade 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -33,12 +33,17 @@ import {
Divider,
Select,
Alert,
+ Input,
+ Modal,
+ Space,
+ theme,
message,
} from 'antd';
import { ArrowUp, Sparkle, SlidersHorizontal, CaretDown } from
'@phosphor-icons/react';
import type { ColumnsType } from 'antd/es/table';
import { useLang } from '../../i18n/LangContext';
-import { AiStreamError, chatStream } from '../../api/ai';
+import { AiStreamError, chatStream, executeTool, listTools, type McpTool }
from '../../api/ai';
+import { listClusters } from '../../api/cluster';
import { getLlmConfig, getLlmModels, type LlmConfig } from '../../api/llm';
import { getChatDraft } from './chatDraft';
@@ -98,6 +103,47 @@ const quickActions = [
'扩缩容评估',
];
+const GLOBAL_TOOL_SCOPE = '__global__';
+
+const isRecord = (value: unknown): value is Record<string, unknown> =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const defaultSchemaValue = (schema: unknown): unknown => {
+ if (!isRecord(schema)) return '';
+ if ('default' in schema) return schema.default;
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return
schema.enum[0];
+ switch (schema.type) {
+ case 'boolean':
+ return false;
+ case 'integer':
+ case 'number':
+ return 0;
+ case 'array':
+ return [];
+ case 'object':
+ return {};
+ default:
+ return '';
+ }
+};
+
+const buildToolInputTemplate = (tool: McpTool, cluster?: string): string => {
+ const required = Array.isArray(tool.parameters.required)
+ ? tool.parameters.required.filter((field): field is string => typeof field
=== 'string')
+ : [];
+ const properties = isRecord(tool.parameters.properties) ?
tool.parameters.properties : {};
+ const input = Object.fromEntries(
+ required.map((field) => [
+ field,
+ field === 'cluster' && cluster ? cluster :
defaultSchemaValue(properties[field]),
+ ]),
+ );
+ return JSON.stringify(input, null, 2);
+};
+
+const formatToolResult = (result: unknown): string =>
+ typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ??
'null');
+
/* ─── Sub-components ─── */
const UserBubble = ({ text }: { text: string }) => (
@@ -260,6 +306,7 @@ const AiPage = () => {
const { t } = useLang();
const location = useLocation();
const navigate = useNavigate();
+ const { token } = theme.useToken();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
@@ -267,6 +314,16 @@ const AiPage = () => {
const [modelOptions, setModelOptions] = useState<{ value: string; label:
string }[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
+ const [toolModalOpen, setToolModalOpen] = useState(false);
+ const [tools, setTools] = useState<McpTool[]>([]);
+ const [toolsLoading, setToolsLoading] = useState(false);
+ const [clusterOptions, setClusterOptions] = useState<{ value: string; label:
string }[]>([]);
+ const [clustersLoading, setClustersLoading] = useState(false);
+ const [selectedClusterId, setSelectedClusterId] = useState('');
+ const [selectedToolName, setSelectedToolName] = useState('');
+ const [toolInput, setToolInput] = useState('{}');
+ const [toolResult, setToolResult] = useState<unknown>(undefined);
+ const [toolExecuting, setToolExecuting] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -408,9 +465,7 @@ const AiPage = () => {
const errorHint = error instanceof AiStreamError && error.hint ?
error.hint : '';
const summary = errorHint ? `${errorMessage}\n\n> ${errorHint}` :
errorMessage;
setMessages((prev) =>
- prev.map((item) =>
- item.id === responseId ? { ...item, summary } : item,
- ),
+ prev.map((item) => (item.id === responseId ? { ...item, summary } :
item)),
);
message.error(errorMessage);
}
@@ -439,6 +494,96 @@ const AiPage = () => {
textareaRef.current?.focus();
}, []);
+ const selectTool = useCallback(
+ (name: string, availableTools: McpTool[] = tools, clusterId: string =
selectedClusterId) => {
+ const tool = availableTools.find((item) => item.name === name);
+ setSelectedToolName(name);
+ setToolInput(tool ? buildToolInputTemplate(tool, clusterId) : '{}');
+ setToolResult(undefined);
+ },
+ [selectedClusterId, tools],
+ );
+
+ const loadTools = useCallback(
+ async (clusterId: string) => {
+ setSelectedToolName('');
+ setToolResult(undefined);
+ setToolsLoading(true);
+ try {
+ const availableTools = await listTools(clusterId || undefined);
+ setTools(availableTools);
+ const firstTool = availableTools.find((tool) => !tool.deprecated);
+ if (firstTool) selectTool(firstTool.name, availableTools, clusterId);
+ } catch {
+ setTools([]);
+ message.error('AI 工具目录加载失败');
+ } finally {
+ setToolsLoading(false);
+ }
+ },
+ [selectTool],
+ );
+
+ const handleOpenTools = useCallback(async () => {
+ setToolModalOpen(true);
+ setToolResult(undefined);
+ if (tools.length > 0 || toolsLoading || clustersLoading) return;
+
+ let clusterId = '';
+ setClustersLoading(true);
+ try {
+ const clusters = await listClusters();
+ const options = clusters.map((cluster) => ({ value: cluster.id, label:
cluster.name }));
+ setClusterOptions(options);
+ clusterId = options[0]?.value ?? '';
+ setSelectedClusterId(clusterId);
+ } catch {
+ message.warning('集群列表加载失败,已显示全局工具');
+ } finally {
+ setClustersLoading(false);
+ }
+
+ await loadTools(clusterId);
+ }, [clustersLoading, loadTools, tools.length, toolsLoading]);
+
+ const handleClusterChange = useCallback(
+ async (scope: string) => {
+ const clusterId = scope === GLOBAL_TOOL_SCOPE ? '' : scope;
+ setSelectedClusterId(clusterId);
+ await loadTools(clusterId);
+ },
+ [loadTools],
+ );
+
+ const handleExecuteTool = useCallback(async () => {
+ if (!selectedToolName || toolExecuting) return;
+
+ let parsedInput: unknown;
+ try {
+ parsedInput = JSON.parse(toolInput || '{}');
+ } catch {
+ message.error('工具参数必须是有效的 JSON 对象');
+ return;
+ }
+ if (!isRecord(parsedInput)) {
+ message.error('工具参数必须是有效的 JSON 对象');
+ return;
+ }
+
+ setToolExecuting(true);
+ setToolResult(undefined);
+ try {
+ setToolResult(await executeTool(selectedToolName, parsedInput));
+ message.success('工具执行成功');
+ } catch {
+ message.error('工具执行失败');
+ } finally {
+ setToolExecuting(false);
+ }
+ }, [selectedToolName, toolExecuting, toolInput]);
+
+ const selectedTool = tools.find((tool) => tool.name === selectedToolName);
+
return (
<Flex vertical style={{ height: '100%', minHeight: 0, padding: 24,
overflow: 'hidden' }}>
{/* Chat Area */}
@@ -632,7 +777,7 @@ const AiPage = () => {
<div className="flex items-center gap-2 w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 overflow-x-auto
scrollbar-hide max-w-full py-2">
- <button className="tool-btn">
+ <button className="tool-btn" onClick={() => void
handleOpenTools()}>
<SlidersHorizontal size={17} />
<span>工具</span>
</button>
@@ -667,6 +812,98 @@ const AiPage = () => {
</div>
</div>
+ <Modal
+ title="AI 工具"
+ open={toolModalOpen}
+ onCancel={() => setToolModalOpen(false)}
+ onOk={() => void handleExecuteTool()}
+ okText="执行"
+ cancelText="关闭"
+ width={720}
+ styles={{ body: { maxHeight: 'calc(100vh - 260px)', overflowY: 'auto'
} }}
+ okButtonProps={{
+ loading: toolExecuting,
+ disabled: toolsLoading || !selectedToolName,
+ }}
+ >
+ <Flex vertical gap={16} style={{ paddingTop: 8 }}>
+ <Select
+ aria-label="选择集群"
+ loading={clustersLoading}
+ value={selectedClusterId || GLOBAL_TOOL_SCOPE}
+ onChange={(scope) => void handleClusterChange(scope)}
+ options={[{ value: GLOBAL_TOOL_SCOPE, label: '全局工具' },
...clusterOptions]}
+ />
+
+ <Select
+ aria-label="选择工具"
+ showSearch
+ loading={toolsLoading}
+ value={selectedToolName || undefined}
+ placeholder="选择工具"
+ optionFilterProp="label"
+ onChange={(name) => selectTool(name)}
+ options={tools.map((tool) => ({
+ value: tool.name,
+ label: tool.name,
+ disabled: tool.deprecated,
+ }))}
+ />
+
+ {selectedTool && (
+ <Flex vertical gap={8}>
+ <Space size={8} wrap>
+ {selectedTool.riskLevel && (
+ <Tag color={selectedTool.riskLevel === 'L1' ? 'green' :
'orange'}>
+ {selectedTool.riskLevel}
+ </Tag>
+ )}
+ {selectedTool.permission &&
<Tag>{selectedTool.permission}</Tag>}
+ </Space>
+ <Text type="secondary">{selectedTool.description}</Text>
+ </Flex>
+ )}
+
+ <div>
+ <Text strong style={{ display: 'block', marginBottom: 8 }}>
+ 输入参数 (JSON)
+ </Text>
+ <Input.TextArea
+ aria-label="工具参数 JSON"
+ value={toolInput}
+ onChange={(event) => setToolInput(event.target.value)}
+ autoSize={{ minRows: 6, maxRows: 12 }}
+ spellCheck={false}
+ />
+ </div>
+
+ {toolResult !== undefined && (
+ <div>
+ <Text strong style={{ display: 'block', marginBottom: 8 }}>
+ 执行结果
+ </Text>
+ <pre
+ data-testid="tool-result"
+ style={{
+ maxHeight: 280,
+ margin: 0,
+ padding: 12,
+ overflow: 'auto',
+ color: token.colorText,
+ border: `1px solid ${token.colorBorderSecondary}`,
+ borderRadius: 6,
+ background: token.colorFillQuaternary,
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ }}
+ >
+ {formatToolResult(toolResult)}
+ </pre>
+ </div>
+ )}
+ </Flex>
+ </Modal>
+
{/* Keyframes for loading animation */}
<style>{`
@keyframes dotPulse {