feat: add tools API endpoint and tools-selector form type (#2103)

* feat: add tools API endpoint and tools-selector form type

Backend:
- Add GET /api/v1/tools — list all available tools (plugin + MCP)
- Add GET /api/v1/tools/<tool_name> — get specific tool details

Frontend:
- Add TOOLS_SELECTOR form type for plugin config forms
- Multi-select dialog with tool name and description
- Add PluginTool entity type and API client methods

* fix: remove unused quart import, fix prettier formatting

* style: ruff format tools.py

* chore: bump langbot-plugin to 0.3.7
This commit is contained in:
Junyan Chin
2026-04-03 17:45:10 +08:00
committed by GitHub
parent 2317392ee5
commit 875227a2fe
7 changed files with 231 additions and 1 deletions

View File

@@ -64,7 +64,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3", "pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.6", "langbot-plugin==0.3.7",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10", "tboxsdk>=0.0.10",

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from ... import group
@group.group_class('tools', '/api/v1/tools')
class ToolsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取所有可用工具列表"""
tools = await self.ap.tool_mgr.get_all_tools()
tool_list = []
for tool in tools:
tool_list.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
)
return self.success(data={'tools': tool_list})
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools()
for tool in tools:
if tool.name == tool_name:
return self.success(
data={
'tool': {
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
}
)
return self.http_status(404, -1, f'Tool not found: {tool_name}')

View File

@@ -249,6 +249,9 @@ export default function DynamicFormComponent({
case 'bot-selector': case 'bot-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector': case 'model-fallback-selector':
fieldSchema = z.object({ fieldSchema = z.object({
primary: z.string(), primary: z.string(),

View File

@@ -23,6 +23,7 @@ import {
Bot, Bot,
KnowledgeBase, KnowledgeBase,
EmbeddingModel, EmbeddingModel,
PluginTool,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -75,9 +76,14 @@ export default function DynamicFormItemComponent({
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]); const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]); const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
const [uploading, setUploading] = useState<boolean>(false); const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false); const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]); const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
[],
);
const { t } = useTranslation(); const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false); const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
@@ -209,6 +215,21 @@ export default function DynamicFormItemComponent({
} }
}, [config.type]); }, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
httpClient
.getTools()
.then((resp) => {
setTools(resp.tools);
})
.catch((err) => {
toast.error(
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
);
});
}
}, [config.type]);
switch (config.type) { switch (config.type) {
case DynamicFormItemType.INT: case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT: case DynamicFormItemType.FLOAT:
@@ -1161,6 +1182,139 @@ export default function DynamicFormItemComponent({
</Select> </Select>
); );
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
);
return (
<div
key={toolName}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium">{toolName}</div>
{currentTool?.human_desc && (
<div className="text-sm text-muted-foreground truncate">
{currentTool.human_desc}
</div>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(name: string) => name !== toolName,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('tools.noToolSelected', 'No tools selected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedToolNames(field.value || []);
setToolsDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('tools.addTool', 'Add Tool')}
</Button>
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{t('tools.selectTools', 'Select Tools')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{tools.map((tool) => {
const isSelected = tempSelectedToolNames.includes(tool.name);
return (
<div
key={tool.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
setTempSelectedToolNames((prev) =>
prev.includes(tool.name)
? prev.filter((name) => name !== tool.name)
: [...prev, tool.name],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${tool.name}`}
/>
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">{tool.name}</div>
{tool.human_desc && (
<div className="text-sm text-muted-foreground">
{tool.human_desc}
</div>
)}
</div>
</div>
);
})}
{tools.length === 0 && (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('tools.noToolsAvailable', 'No tools available')}
</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setToolsDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedToolNames);
setToolsDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
case DynamicFormItemType.PROMPT_EDITOR: { case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or // Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default // initialValues haven't propagated yet. Fall back to a default

View File

@@ -465,3 +465,18 @@ export interface MCPTool {
description: string; description: string;
parameters?: object; parameters?: object;
} }
export interface PluginTool {
name: string;
description: string;
human_desc: string;
parameters: object;
}
export interface ApiRespTools {
tools: PluginTool[];
}
export interface ApiRespToolDetail {
tool: PluginTool;
}

View File

@@ -42,6 +42,7 @@ export enum DynamicFormItemType {
KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector', KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector',
PLUGIN_SELECTOR = 'plugin-selector', PLUGIN_SELECTOR = 'plugin-selector',
BOT_SELECTOR = 'bot-selector', BOT_SELECTOR = 'bot-selector',
TOOLS_SELECTOR = 'tools-selector',
WEBHOOK_URL = 'webhook-url', WEBHOOK_URL = 'webhook-url',
} }

View File

@@ -41,6 +41,8 @@ import {
ApiRespKnowledgeEngines, ApiRespKnowledgeEngines,
ApiRespParsers, ApiRespParsers,
RagMigrationStatusResp, RagMigrationStatusResp,
ApiRespTools,
ApiRespToolDetail,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin'; import { Plugin } from '@/app/infra/entities/plugin';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
@@ -649,6 +651,16 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/mcp/servers'); return this.get('/api/v1/mcp/servers');
} }
// ========== Tools ==========
public getTools(): Promise<ApiRespTools> {
return this.get('/api/v1/tools');
}
public getToolDetail(toolName: string): Promise<ApiRespToolDetail> {
return this.get(`/api/v1/tools/${toolName}`);
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> { public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`); return this.get(`/api/v1/mcp/servers/${serverName}`);
} }