feat: add all can bundle to provider

This commit is contained in:
Joel
2026-01-23 17:16:53 +08:00
parent 88887ea58e
commit 6551814396
10 changed files with 371 additions and 15 deletions

View File

@ -35,6 +35,8 @@ import {
ToolBlock,
ToolBlockNode,
ToolBlockReplacementBlock,
ToolGroupBlockNode,
ToolGroupBlockReplacementBlock,
} from '@/app/components/workflow/skill/editor/skill-editor/plugins/tool-block'
import { ToolBlockContextProvider } from '@/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-context'
import ToolPickerBlock from '@/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block'
@ -167,7 +169,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
CurrentBlockNode,
ErrorMessageBlockNode,
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
...(isSupportSandbox ? [FileReferenceNode, ToolBlockNode] : []),
...(isSupportSandbox ? [FileReferenceNode, ToolGroupBlockNode, ToolBlockNode] : []),
],
editorState: textToEditorState(value || ''),
onError: (error: Error) => {
@ -266,6 +268,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
{isSupportSandbox && (
<>
<ToolBlock />
<ToolGroupBlockReplacementBlock />
<ToolBlockReplacementBlock />
{editable && <ToolPickerBlock />}
</>

View File

@ -54,6 +54,7 @@ type AllToolsProps = {
showFeatured?: boolean
onFeaturedInstallSuccess?: () => Promise<void> | void
hideFeaturedTool?: boolean
hideSelectedInfo?: boolean
}
const DEFAULT_TAGS: AllToolsProps['tags'] = []
@ -78,6 +79,7 @@ const AllTools = ({
showFeatured = false,
onFeaturedInstallSuccess,
hideFeaturedTool = false,
hideSelectedInfo = false,
}: AllToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
@ -277,7 +279,7 @@ const AllTools = ({
viewType={isSupportGroupView ? activeView : ViewType.flat}
hasSearchText={hasSearchText}
selectedTools={selectedTools}
hideSelectedInfo={hideFeaturedTool}
hideSelectedInfo={hideSelectedInfo}
/>
</>
)}

View File

@ -53,6 +53,7 @@ type Props = {
selectedTools?: ToolValue[]
preventFocusLoss?: boolean
hideFeaturedTool?: boolean
hideSelectedInfo?: boolean
}
const ToolPicker: FC<Props> = ({
@ -71,6 +72,7 @@ const ToolPicker: FC<Props> = ({
panelClassName,
preventFocusLoss = false,
hideFeaturedTool = false,
hideSelectedInfo = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
@ -220,6 +222,7 @@ const ToolPicker: FC<Props> = ({
featuredLoading={isFeaturedLoading}
showFeatured={scope === 'all' && enable_marketplace}
hideFeaturedTool={hideFeaturedTool}
hideSelectedInfo={hideSelectedInfo}
onFeaturedInstallSuccess={async () => {
invalidateBuiltInTools()
invalidateCustomTools()

View File

@ -28,6 +28,8 @@ import {
ToolBlock,
ToolBlockNode,
ToolBlockReplacementBlock,
ToolGroupBlockNode,
ToolGroupBlockReplacementBlock,
} from './plugins/tool-block'
import ToolPickerBlock from './plugins/tool-block/tool-picker-block'
@ -73,6 +75,7 @@ const SkillEditor: FC<SkillEditorProps> = ({
replace: TextNode,
with: (node: TextNode) => new CustomTextNode(node.__text),
},
ToolGroupBlockNode,
ToolBlockNode,
FileReferenceNode,
],
@ -123,6 +126,7 @@ const SkillEditor: FC<SkillEditorProps> = ({
/>
<>
<ToolBlock />
<ToolGroupBlockReplacementBlock />
<ToolBlockReplacementBlock />
<FileReferenceReplacementBlock />
{editable && <FilePickerBlock />}

View File

@ -44,3 +44,5 @@ ToolBlock.displayName = 'ToolBlock'
export { ToolBlock }
export { ToolBlockNode } from './node'
export { default as ToolBlockReplacementBlock } from './tool-block-replacement-block'
export { ToolGroupBlockNode } from './tool-group-block-node'
export { default as ToolGroupBlockReplacementBlock } from './tool-group-block-replacement-block'

View File

@ -0,0 +1,119 @@
import type { FC } from 'react'
import type { ToolToken } from './utils'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import * as React from 'react'
import { useMemo } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { Theme } from '@/types/app'
import { canFindTool } from '@/utils'
import { cn } from '@/utils/classnames'
import { basePath } from '@/utils/var'
import { DELETE_TOOL_BLOCK_COMMAND } from './index'
type ToolGroupBlockComponentProps = {
nodeKey: string
tools: ToolToken[]
}
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
if (!icon)
return icon
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
return `${basePath}${icon}`
return icon
}
const ToolGroupBlockComponent: FC<ToolGroupBlockComponentProps> = ({
nodeKey,
tools,
}) => {
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_TOOL_BLOCK_COMMAND)
const language = useGetLanguage()
const { theme } = useTheme()
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const mergedTools = useMemo(() => {
return [buildInTools, customTools, workflowTools, mcpTools].filter(Boolean) as ToolWithProvider[][]
}, [buildInTools, customTools, workflowTools, mcpTools])
const providerId = tools[0]?.provider || ''
const currentProvider = useMemo(() => {
if (!providerId)
return undefined
for (const collection of mergedTools) {
const providerItem = collection.find(item => item.name === providerId || item.id === providerId || canFindTool(item.id, providerId))
if (providerItem)
return providerItem
}
return undefined
}, [mergedTools, providerId])
const providerLabel = currentProvider?.label?.[language] || currentProvider?.name || providerId
const resolvedIcon = (() => {
const fromMeta = theme === Theme.dark ? currentProvider?.icon_dark : currentProvider?.icon
return normalizeProviderIcon(fromMeta)
})()
const renderIcon = () => {
if (!resolvedIcon)
return null
if (typeof resolvedIcon === 'string') {
if (resolvedIcon.startsWith('http') || resolvedIcon.startsWith('/')) {
return (
<span
className="h-4 w-4 shrink-0 rounded-[4px] bg-cover bg-center"
style={{ backgroundImage: `url(${resolvedIcon})` }}
/>
)
}
return (
<AppIcon
size="xs"
icon={resolvedIcon}
className="!h-4 !w-4 shrink-0 !border-0"
/>
)
}
return (
<AppIcon
size="xs"
icon={resolvedIcon.content}
background={resolvedIcon.background}
className="!h-4 !w-4 shrink-0 !border-0"
/>
)
}
return (
<span
ref={ref}
className={cn(
'inline-flex items-center gap-[2px] rounded-[5px] border border-state-accent-hover-alt bg-state-accent-hover px-[4px] py-[1px] shadow-xs',
isSelected && 'border-text-accent',
)}
title={providerLabel}
>
{renderIcon()}
<span className="system-xs-medium max-w-[160px] truncate text-text-accent">
{providerLabel}
</span>
<span className="system-2xs-medium-uppercase rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[4px] py-[2px] text-text-accent-secondary">
{tools.length}
</span>
</span>
)
}
export default React.memo(ToolGroupBlockComponent)

View File

@ -0,0 +1,82 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { ToolToken } from './utils'
import { DecoratorNode } from 'lexical'
import ToolGroupBlockComponent from './tool-group-block-component'
import { buildToolTokenList } from './utils'
export type ToolGroupBlockPayload = {
tools: ToolToken[]
}
export type SerializedToolGroupBlockNode = SerializedLexicalNode & ToolGroupBlockPayload
export class ToolGroupBlockNode extends DecoratorNode<React.JSX.Element> {
__tools: ToolToken[]
static getType(): string {
return 'tool-group-block'
}
static clone(node: ToolGroupBlockNode): ToolGroupBlockNode {
return new ToolGroupBlockNode(
{
tools: node.__tools,
},
node.__key,
)
}
isInline(): boolean {
return true
}
constructor(payload: ToolGroupBlockPayload, key?: NodeKey) {
super(key)
this.__tools = payload.tools
}
createDOM(): HTMLElement {
const span = document.createElement('span')
span.classList.add('inline-flex', 'items-center', 'align-middle')
return span
}
updateDOM(): false {
return false
}
decorate(): React.JSX.Element {
return (
<ToolGroupBlockComponent
nodeKey={this.getKey()}
tools={this.__tools}
/>
)
}
exportJSON(): SerializedToolGroupBlockNode {
return {
type: 'tool-group-block',
version: 1,
tools: this.__tools,
}
}
static importJSON(serializedNode: SerializedToolGroupBlockNode): ToolGroupBlockNode {
return $createToolGroupBlockNode(serializedNode)
}
getTextContent(): string {
return buildToolTokenList(this.__tools)
}
}
export function $createToolGroupBlockNode(payload: ToolGroupBlockPayload): ToolGroupBlockNode {
return new ToolGroupBlockNode(payload)
}
export function $isToolGroupBlockNode(
node: ToolGroupBlockNode | LexicalNode | null | undefined,
): node is ToolGroupBlockNode {
return node instanceof ToolGroupBlockNode
}

View File

@ -0,0 +1,98 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $createTextNode, $isTextNode } from 'lexical'
import { useEffect, useMemo } from 'react'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
import { $createToolGroupBlockNode, ToolGroupBlockNode } from './tool-group-block-node'
import { getToolTokenListRegexString, parseToolTokenList } from './utils'
const decoratorTransformAllowAdjacent = (
node: CustomTextNode,
getMatch: (text: string) => null | { start: number, end: number },
createNode: (textNode: CustomTextNode) => ReturnType<typeof $createTextNode> | ToolGroupBlockNode,
) => {
if (!node.isSimpleText())
return
const prevSibling = node.getPreviousSibling()
let text = node.getTextContent()
let currentNode = node
let match
while (true) {
match = getMatch(text)
let nextText = match === null ? '' : text.slice(match.end)
text = nextText
if (nextText === '') {
const nextSibling = currentNode.getNextSibling()
if ($isTextNode(nextSibling)) {
nextText = currentNode.getTextContent() + nextSibling.getTextContent()
const nextMatch = getMatch(nextText)
if (nextMatch === null) {
nextSibling.markDirty()
return
}
else if (nextMatch.start !== 0) {
return
}
}
}
if (match === null)
return
if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity())
continue
let nodeToReplace
if (match.start === 0)
[nodeToReplace, currentNode] = currentNode.splitText(match.end)
else
[, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end)
const replacementNode = createNode(nodeToReplace as CustomTextNode)
nodeToReplace.replace(replacementNode)
if (currentNode == null)
return
}
}
const ToolGroupBlockReplacementBlock = () => {
const [editor] = useLexicalComposerContext()
const regex = useMemo(() => new RegExp(getToolTokenListRegexString(), 'i'), [])
useEffect(() => {
if (!editor.hasNodes([ToolGroupBlockNode]))
throw new Error('ToolGroupBlockReplacementBlock: ToolGroupBlockNode not registered on editor')
const getMatch = (text: string) => {
const matchArr = regex.exec(text)
if (!matchArr)
return null
return {
start: matchArr.index,
end: matchArr.index + matchArr[0].length,
}
}
const createToolGroupBlockNode = (textNode: CustomTextNode) => {
const parsed = parseToolTokenList(textNode.getTextContent())
if (!parsed)
return $createTextNode(textNode.getTextContent())
return $createToolGroupBlockNode({ tools: parsed })
}
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransformAllowAdjacent(textNode, getMatch, createToolGroupBlockNode)),
)
}, [editor, regex])
return null
}
export default ToolGroupBlockReplacementBlock

View File

@ -20,6 +20,7 @@ import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { $createToolBlockNode } from './node'
import { useToolBlockContext } from './tool-block-context'
import { $createToolGroupBlockNode } from './tool-group-block-node'
class ToolPickerMenuOption extends MenuOption {
constructor() {
@ -55,20 +56,31 @@ const ToolPickerBlock: FC<ToolPickerBlockProps> = ({ scope = 'all' }) => {
nodeToRemove.remove()
const nodes: LexicalNode[] = []
toolEntries.forEach(({ tool, configId }, index) => {
nodes.push(
$createToolBlockNode({
if (toolEntries.length > 1) {
nodes.push($createToolGroupBlockNode({
tools: toolEntries.map(({ tool, configId }) => ({
provider: tool.provider_id,
tool: tool.tool_name,
configId,
label: tool.tool_label,
icon: tool.provider_icon,
iconDark: tool.provider_icon_dark,
}),
)
if (index !== tools.length - 1)
nodes.push($createTextNode(' '))
})
})),
}))
}
else {
toolEntries.forEach(({ tool, configId }, index) => {
nodes.push(
$createToolBlockNode({
provider: tool.provider_id,
tool: tool.tool_name,
configId,
label: tool.tool_label,
icon: tool.provider_icon,
iconDark: tool.provider_icon_dark,
}),
)
if (index !== tools.length - 1)
nodes.push($createTextNode(' '))
})
}
if (nodes.length)
$insertNodes(nodes)

View File

@ -1,8 +1,19 @@
export type ToolToken = {
provider: string
tool: string
configId: string
}
export const getToolTokenRegexString = (): string => {
return '§\\[tool\\]\\.\\[[a-zA-Z0-9_-]+(?:\\/[a-zA-Z0-9_-]+)*\\]\\.\\[[a-zA-Z0-9_-]+\\]\\.\\[[a-fA-F0-9-]{36}\\]§'
}
export const parseToolToken = (text: string) => {
export const getToolTokenListRegexString = (): string => {
const token = getToolTokenRegexString()
return `\\[(?:${token})(?:\\s*,\\s*${token})*\\]`
}
export const parseToolToken = (text: string): ToolToken | null => {
const match = /^§\[tool\]\.\[([\w-]+(?:\/[\w-]+)*)\]\.\[([\w-]+)\]\.\[([a-fA-F0-9-]{36})\]§$/.exec(text)
if (!match)
return null
@ -13,6 +24,26 @@ export const parseToolToken = (text: string) => {
}
}
export const buildToolToken = (payload: { provider: string, tool: string, configId: string }) => {
export const parseToolTokenList = (text: string): ToolToken[] | null => {
const trimmed = text.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']'))
return null
const content = trimmed.slice(1, -1).trim()
if (!content)
return null
const tokens = content.split(',').map(token => token.trim()).filter(Boolean)
if (!tokens.length)
return null
const parsed = tokens.map(token => parseToolToken(token))
if (parsed.some(item => !item))
return null
return parsed as ToolToken[]
}
export const buildToolToken = (payload: ToolToken) => {
return `§[tool].[${payload.provider}].[${payload.tool}].[${payload.configId}`
}
export const buildToolTokenList = (tokens: ToolToken[]) => {
return `[${tokens.map(buildToolToken).join(',')}]`
}