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 (#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:
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.6",
|
||||
"langbot-plugin==0.3.7",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -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}')
|
||||
@@ -249,6 +249,9 @@ export default function DynamicFormComponent({
|
||||
case 'bot-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'tools-selector':
|
||||
fieldSchema = z.array(z.string());
|
||||
break;
|
||||
case 'model-fallback-selector':
|
||||
fieldSchema = z.object({
|
||||
primary: z.string(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Bot,
|
||||
KnowledgeBase,
|
||||
EmbeddingModel,
|
||||
PluginTool,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -75,9 +76,14 @@ export default function DynamicFormItemComponent({
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
|
||||
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
|
||||
@@ -209,6 +215,21 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
}, [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) {
|
||||
case DynamicFormItemType.INT:
|
||||
case DynamicFormItemType.FLOAT:
|
||||
@@ -1161,6 +1182,139 @@ export default function DynamicFormItemComponent({
|
||||
</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: {
|
||||
// Guard: field.value may be undefined when the form resets or
|
||||
// initialValues haven't propagated yet. Fall back to a default
|
||||
|
||||
@@ -465,3 +465,18 @@ export interface MCPTool {
|
||||
description: string;
|
||||
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',
|
||||
PLUGIN_SELECTOR = 'plugin-selector',
|
||||
BOT_SELECTOR = 'bot-selector',
|
||||
TOOLS_SELECTOR = 'tools-selector',
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
ApiRespKnowledgeEngines,
|
||||
ApiRespParsers,
|
||||
RagMigrationStatusResp,
|
||||
ApiRespTools,
|
||||
ApiRespToolDetail,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
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');
|
||||
}
|
||||
|
||||
// ========== 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> {
|
||||
return this.get(`/api/v1/mcp/servers/${serverName}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user