mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
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}')
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,19 @@ 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 +1180,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user