mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
feat: add all can bundle to provider
This commit is contained in:
@ -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 />}
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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(',')}]`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user