feat: skill editor choose tool

This commit is contained in:
Joel
2026-01-15 17:15:48 +08:00
parent e651c6cacf
commit d650cde323
9 changed files with 602 additions and 2 deletions

View File

@ -41,6 +41,7 @@ type Props = {
panelClassName?: string
disabled: boolean
trigger: React.ReactNode
triggerAsChild?: boolean
placement?: Placement
offset?: OffsetOptions
isShow: boolean
@ -55,6 +56,7 @@ type Props = {
const ToolPicker: FC<Props> = ({
disabled,
trigger,
triggerAsChild = false,
placement = 'right-start',
offset = 0,
isShow,
@ -165,6 +167,7 @@ const ToolPicker: FC<Props> = ({
>
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
asChild={triggerAsChild}
>
{trigger}
</PortalToFollowElemTrigger>

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import SkillEditor from './skill-editor'
type MarkdownFileEditorProps = {
value: string
@ -13,7 +13,7 @@ const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ value, onChange }) =>
return (
<div className="h-full w-full bg-components-panel-bg">
<PromptEditor
<SkillEditor
value={value}
onChange={onChange}
showLineNumbers

View File

@ -0,0 +1,131 @@
'use client'
import type { EditorState } from 'lexical'
import type { FC } from 'react'
import { CodeNode } from '@lexical/code'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import {
$getRoot,
TextNode,
} from 'lexical'
import * as React from 'react'
import styles from '@/app/components/base/prompt-editor/line-numbers.module.css'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
import OnBlurBlock from '@/app/components/base/prompt-editor/plugins/on-blur-or-focus-block'
import Placeholder from '@/app/components/base/prompt-editor/plugins/placeholder'
import UpdateBlock from '@/app/components/base/prompt-editor/plugins/update-block'
import { textToEditorState } from '@/app/components/base/prompt-editor/utils'
import { cn } from '@/utils/classnames'
import {
ToolBlock,
ToolBlockNode,
ToolBlockReplacementBlock,
} from './plugins/tool-block'
import ToolPickerBlock from './plugins/tool-block/tool-picker-block'
export type SkillEditorProps = {
instanceId?: string
compact?: boolean
wrapperClassName?: string
className?: string
placeholder?: string | React.ReactNode
placeholderClassName?: string
showLineNumbers?: boolean
style?: React.CSSProperties
value?: string
editable?: boolean
onChange?: (text: string) => void
onBlur?: () => void
onFocus?: () => void
toolPickerScope?: string
}
const SkillEditor: FC<SkillEditorProps> = ({
instanceId,
compact,
wrapperClassName,
className,
placeholder,
placeholderClassName,
showLineNumbers,
style,
value,
editable = true,
onChange,
onBlur,
onFocus,
toolPickerScope = 'all',
}) => {
const initialConfig = {
namespace: 'skill-editor',
nodes: [
CodeNode,
CustomTextNode,
{
replace: TextNode,
with: (node: TextNode) => new CustomTextNode(node.__text),
},
ToolBlockNode,
],
editorState: textToEditorState(value || ''),
onError: (error: Error) => {
throw error
},
}
const handleEditorChange = (editorState: EditorState) => {
const text = editorState.read(() => {
return $getRoot().getChildren().map(p => p.getTextContent()).join('\n')
})
if (onChange)
onChange(text)
}
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className={cn('relative', showLineNumbers && styles.lineNumbersScope, wrapperClassName)}>
<RichTextPlugin
contentEditable={(
<ContentEditable
className={cn(
'text-text-secondary outline-none',
compact ? 'text-[13px] leading-5' : 'text-sm leading-6',
showLineNumbers && styles.lineNumbers,
className,
)}
style={style || {}}
/>
)}
placeholder={(
<Placeholder
value={placeholder}
className={cn(
'truncate',
showLineNumbers && styles.lineNumbersPlaceholder,
placeholderClassName,
)}
compact={compact}
/>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
<>
<ToolBlock />
<ToolBlockReplacementBlock />
{editable && <ToolPickerBlock scope={toolPickerScope} />}
</>
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
</div>
</LexicalComposer>
)
}
export default SkillEditor

View File

@ -0,0 +1,129 @@
import type { FC } from 'react'
import type { Emoji } from '@/app/components/tools/types'
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 ToolBlockComponentProps = {
nodeKey: string
provider: string
tool: string
label?: string
icon?: string | Emoji
iconDark?: string | Emoji
}
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 ToolBlockComponent: FC<ToolBlockComponentProps> = ({
nodeKey,
provider,
tool,
label,
icon,
iconDark,
}) => {
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 toolMeta = useMemo(() => {
const collections = [buildInTools, customTools, workflowTools, mcpTools].filter(Boolean) as ToolWithProvider[][]
for (const collection of collections) {
const providerItem = collection.find(item => item.name === provider || item.id === provider || canFindTool(item.id, provider))
if (!providerItem)
continue
const toolItem = providerItem.tools?.find(item => item.name === tool)
if (!toolItem)
continue
return {
label: toolItem.label?.[language] || tool,
icon: providerItem.icon,
iconDark: providerItem.icon_dark,
}
}
return null
}, [buildInTools, customTools, workflowTools, mcpTools, language, provider, tool])
const displayLabel = label || toolMeta?.label || tool
const resolvedIcon = (() => {
const fromNode = theme === Theme.dark ? iconDark : icon
if (fromNode)
return normalizeProviderIcon(fromNode)
const fromMeta = theme === Theme.dark ? toolMeta?.iconDark : toolMeta?.icon
return normalizeProviderIcon(fromMeta)
})()
const renderIcon = () => {
if (!resolvedIcon)
return null
if (typeof resolvedIcon === 'string') {
if (resolvedIcon.startsWith('http') || resolvedIcon.startsWith('/')) {
return (
<span
className="h-[14px] w-[14px] shrink-0 rounded-[3px] bg-cover bg-center"
style={{ backgroundImage: `url(${resolvedIcon})` }}
/>
)
}
return (
<AppIcon
size="xs"
icon={resolvedIcon}
className="!h-[14px] !w-[14px] shrink-0 !border-0"
/>
)
}
return (
<AppIcon
size="xs"
icon={resolvedIcon.content}
background={resolvedIcon.background}
className="!h-[14px] !w-[14px] 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={`${provider}.${tool}`}
>
{renderIcon()}
<span className="system-xs-medium max-w-[180px] truncate text-text-accent">
{displayLabel}
</span>
</span>
)
}
export default React.memo(ToolBlockComponent)

View File

@ -0,0 +1,46 @@
import type { ToolBlockPayload } from './node'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { memo, useEffect } from 'react'
import { $createToolBlockNode, ToolBlockNode } from './node'
export const INSERT_TOOL_BLOCK_COMMAND = createCommand<ToolBlockPayload>('INSERT_TOOL_BLOCK_COMMAND')
export const DELETE_TOOL_BLOCK_COMMAND = createCommand('DELETE_TOOL_BLOCK_COMMAND')
const ToolBlock = memo(() => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([ToolBlockNode]))
throw new Error('ToolBlockPlugin: ToolBlockNode not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_TOOL_BLOCK_COMMAND,
(payload: ToolBlockPayload) => {
const toolBlockNode = $createToolBlockNode(payload)
$insertNodes([toolBlockNode])
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_TOOL_BLOCK_COMMAND,
() => true,
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return null
})
ToolBlock.displayName = 'ToolBlock'
export { ToolBlock }
export { ToolBlockNode } from './node'
export { default as ToolBlockReplacementBlock } from './tool-block-replacement-block'

View File

@ -0,0 +1,115 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { Emoji } from '@/app/components/tools/types'
import { DecoratorNode } from 'lexical'
import ToolBlockComponent from './component'
import { buildToolToken } from './utils'
export type ToolBlockPayload = {
provider: string
tool: string
configId: string
label?: string
icon?: string | Emoji
iconDark?: string | Emoji
}
export type SerializedToolBlockNode = SerializedLexicalNode & ToolBlockPayload
export class ToolBlockNode extends DecoratorNode<React.JSX.Element> {
__provider: string
__tool: string
__configId: string
__label?: string
__icon?: string | Emoji
__iconDark?: string | Emoji
static getType(): string {
return 'tool-block'
}
static clone(node: ToolBlockNode): ToolBlockNode {
return new ToolBlockNode(
{
provider: node.__provider,
tool: node.__tool,
configId: node.__configId,
label: node.__label,
icon: node.__icon,
iconDark: node.__iconDark,
},
node.__key,
)
}
isInline(): boolean {
return true
}
constructor(payload: ToolBlockPayload, key?: NodeKey) {
super(key)
this.__provider = payload.provider
this.__tool = payload.tool
this.__configId = payload.configId
this.__label = payload.label
this.__icon = payload.icon
this.__iconDark = payload.iconDark
}
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 (
<ToolBlockComponent
nodeKey={this.getKey()}
provider={this.__provider}
tool={this.__tool}
label={this.__label}
icon={this.__icon}
iconDark={this.__iconDark}
/>
)
}
exportJSON(): SerializedToolBlockNode {
return {
type: 'tool-block',
version: 1,
provider: this.__provider,
tool: this.__tool,
configId: this.__configId,
label: this.__label,
icon: this.__icon,
iconDark: this.__iconDark,
}
}
static importJSON(serializedNode: SerializedToolBlockNode): ToolBlockNode {
return $createToolBlockNode(serializedNode)
}
getTextContent(): string {
return buildToolToken({
provider: this.__provider,
tool: this.__tool,
configId: this.__configId,
})
}
}
export function $createToolBlockNode(payload: ToolBlockPayload): ToolBlockNode {
return new ToolBlockNode(payload)
}
export function $isToolBlockNode(
node: ToolBlockNode | LexicalNode | null | undefined,
): node is ToolBlockNode {
return node instanceof ToolBlockNode
}

View File

@ -0,0 +1,43 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $createTextNode } from 'lexical'
import { useEffect, useMemo } from 'react'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
import { decoratorTransform } from '@/app/components/base/prompt-editor/utils'
import { $createToolBlockNode, ToolBlockNode } from './node'
import { getToolTokenRegexString, parseToolToken } from './utils'
const ToolBlockReplacementBlock = () => {
const [editor] = useLexicalComposerContext()
const regex = useMemo(() => new RegExp(getToolTokenRegexString(), 'i'), [])
useEffect(() => {
if (!editor.hasNodes([ToolBlockNode]))
throw new Error('ToolBlockReplacementBlock: ToolBlockNode 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 createToolBlockNode = (textNode: CustomTextNode) => {
const parsed = parseToolToken(textNode.getTextContent())
if (!parsed)
return $createTextNode(textNode.getTextContent())
return $createToolBlockNode(parsed)
}
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createToolBlockNode)),
)
}, [editor, regex])
return null
}
export default ToolBlockReplacementBlock

View File

@ -0,0 +1,115 @@
import type { LexicalNode } from 'lexical'
import type { FC } from 'react'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin, MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import {
$createTextNode,
$insertNodes,
} from 'lexical'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { v4 as uuid } from 'uuid'
import { useBasicTypeaheadTriggerMatch } from '@/app/components/base/prompt-editor/hooks'
import { $splitNodeContainingQuery } from '@/app/components/base/prompt-editor/utils'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { $createToolBlockNode } from './node'
class ToolPickerMenuOption extends MenuOption {
constructor() {
super('tool-picker')
}
}
type ToolPickerBlockProps = {
scope?: string
}
const ToolPickerBlock: FC<ToolPickerBlockProps> = ({ scope = 'all' }) => {
const [editor] = useLexicalComposerContext()
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', {
minLength: 0,
maxLength: 0,
})
const options = useMemo(() => [new ToolPickerMenuOption()], [])
const insertTools = useCallback((tools: ToolDefaultValue[]) => {
editor.update(() => {
const match = checkForTriggerMatch('@', editor)
const nodeToRemove = match ? $splitNodeContainingQuery(match) : null
if (nodeToRemove)
nodeToRemove.remove()
const nodes: LexicalNode[] = []
tools.forEach((tool, index) => {
nodes.push(
$createToolBlockNode({
provider: tool.provider_name,
tool: tool.tool_name,
configId: uuid(),
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)
})
}, [checkForTriggerMatch, editor])
const renderMenu = useCallback((
anchorElementRef: React.RefObject<HTMLElement | null>,
{ selectOptionAndCleanUp }: { selectOptionAndCleanUp: (option: MenuOption) => void },
) => {
if (!anchorElementRef.current)
return null
const closeMenu = () => selectOptionAndCleanUp(options[0])
return ReactDOM.createPortal(
<ToolPicker
disabled={false}
trigger={(
<span className="inline-block h-0 w-0" />
)}
triggerAsChild
placement="bottom-start"
offset={4}
isShow
onShowChange={(isShow) => {
if (!isShow)
closeMenu()
}}
onSelect={(tool) => {
insertTools([tool])
closeMenu()
}}
onSelectMultiple={(tools) => {
insertTools(tools)
closeMenu()
}}
scope={scope}
/>,
anchorElementRef.current,
)
}, [insertTools, options, scope])
return (
<LexicalTypeaheadMenuPlugin
options={options}
onSelectOption={() => {}}
onQueryChange={() => {}}
menuRenderFn={renderMenu}
triggerFn={checkForTriggerMatch}
anchorClassName="z-[999999] translate-y-[calc(-100%-3px)]"
/>
)
}
export default React.memo(ToolPickerBlock)

View File

@ -0,0 +1,18 @@
export const getToolTokenRegexString = (): string => {
return '§tool\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\.[a-fA-F0-9-]{36}§'
}
export const parseToolToken = (text: string) => {
const match = /^§tool\.([\w-]+)\.([\w-]+)\.([a-fA-F0-9-]{36})§$/.exec(text)
if (!match)
return null
return {
provider: match[1],
tool: match[2],
configId: match[3],
}
}
export const buildToolToken = (payload: { provider: string, tool: string, configId: string }) => {
return `§tool.${payload.provider}.${payload.tool}.${payload.configId}§`
}