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 {

Reply via email to