mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
This commit is contained in:
@ -0,0 +1,114 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown'
|
||||
|
||||
describe('variable-in-markdown', () => {
|
||||
describe('rehypeVariable', () => {
|
||||
it('should replace variable tokens with variable elements and preserve surrounding text', () => {
|
||||
const tree = {
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Hello {{#node.field#}} world',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rehypeVariable()(tree)
|
||||
|
||||
expect(tree.children).toEqual([
|
||||
{ type: 'text', value: 'Hello ' },
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'variable',
|
||||
properties: { dataPath: '{{#node.field#}}' },
|
||||
children: [],
|
||||
},
|
||||
{ type: 'text', value: ' world' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore note tokens while processing variable nodes', () => {
|
||||
const tree = {
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Hello {{#$node.field#}} world',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rehypeVariable()(tree)
|
||||
|
||||
expect(tree.children).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Hello {{#$node.field#}} world',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rehypeNotes', () => {
|
||||
it('should replace note tokens with section nodes and update the parent tag name', () => {
|
||||
const tree = {
|
||||
tagName: 'p',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'See {{#$node.title#}} please',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rehypeNotes()(tree)
|
||||
|
||||
expect(tree.tagName).toBe('div')
|
||||
expect(tree.children).toEqual([
|
||||
{ type: 'text', value: 'See ' },
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'section',
|
||||
properties: { dataName: 'title' },
|
||||
children: [],
|
||||
},
|
||||
{ type: 'text', value: ' please' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should format variable paths for display', () => {
|
||||
render(<Variable path="{{#node.field#}}" />)
|
||||
|
||||
expect(screen.getByText('{{node/field}}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render note values and replace node ids with labels for variable defaults', () => {
|
||||
const { rerender } = render(
|
||||
<Note
|
||||
defaultInput={{
|
||||
type: 'variable',
|
||||
selector: ['node-1', 'output'],
|
||||
value: '',
|
||||
}}
|
||||
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Note
|
||||
defaultInput={{
|
||||
type: 'constant',
|
||||
value: 'Plain value',
|
||||
selector: [],
|
||||
}}
|
||||
nodeName={nodeId => nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Plain value')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -4,121 +4,130 @@ import type { FormInputItemDefault } from '../types'
|
||||
const variableRegex = /\{\{#(.+?)#\}\}/g
|
||||
const noteRegex = /\{\{#\$(.+?)#\}\}/g
|
||||
|
||||
export function rehypeVariable() {
|
||||
return (tree: any) => {
|
||||
const iterate = (node: any, index: number, parent: any) => {
|
||||
const value = node.value
|
||||
type MarkdownNode = {
|
||||
type?: string
|
||||
value?: string
|
||||
tagName?: string
|
||||
properties?: Record<string, string>
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
type SplitMatchResult = {
|
||||
tagName: string
|
||||
properties: Record<string, string>
|
||||
}
|
||||
|
||||
const splitTextNode = (
|
||||
value: string,
|
||||
regex: RegExp,
|
||||
createMatchNode: (match: RegExpExecArray) => SplitMatchResult,
|
||||
) => {
|
||||
const parts: MarkdownNode[] = []
|
||||
let lastIndex = 0
|
||||
let match = regex.exec(value)
|
||||
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex)
|
||||
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
|
||||
|
||||
const { tagName, properties } = createMatchNode(match)
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName,
|
||||
properties,
|
||||
children: [],
|
||||
})
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
match = regex.exec(value)
|
||||
}
|
||||
|
||||
if (!parts.length)
|
||||
return parts
|
||||
|
||||
if (lastIndex < value.length)
|
||||
parts.push({ type: 'text', value: value.slice(lastIndex) })
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const visitTextNodes = (
|
||||
node: MarkdownNode,
|
||||
transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null,
|
||||
) => {
|
||||
if (!node.children)
|
||||
return
|
||||
|
||||
let index = 0
|
||||
while (index < node.children.length) {
|
||||
const child = node.children[index]
|
||||
if (child.type === 'text' && typeof child.value === 'string') {
|
||||
const nextNodes = transform(child.value, node)
|
||||
if (nextNodes) {
|
||||
node.children.splice(index, 1, ...nextNodes)
|
||||
index += nextNodes.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
visitTextNodes(child, transform)
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => string) => {
|
||||
return path.replace(/#([^#.]+)([.#])/g, (_, nodeId: string, separator: string) => {
|
||||
return `#${nodeName(nodeId)}${separator}`
|
||||
})
|
||||
}
|
||||
|
||||
const formatVariablePath = (path: string) => {
|
||||
return path.replaceAll('.', '/')
|
||||
.replace('{{#', '{{')
|
||||
.replace('#}}', '}}')
|
||||
}
|
||||
|
||||
export function rehypeVariable() {
|
||||
return (tree: MarkdownNode) => {
|
||||
visitTextNodes(tree, (value) => {
|
||||
variableRegex.lastIndex = 0
|
||||
noteRegex.lastIndex = 0
|
||||
if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) {
|
||||
let m: RegExpExecArray | null
|
||||
let last = 0
|
||||
const parts: any[] = []
|
||||
variableRegex.lastIndex = 0
|
||||
m = variableRegex.exec(value)
|
||||
while (m !== null) {
|
||||
if (m.index > last)
|
||||
parts.push({ type: 'text', value: value.slice(last, m.index) })
|
||||
if (!variableRegex.test(value) || noteRegex.test(value))
|
||||
return null
|
||||
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName: 'variable',
|
||||
properties: { dataPath: m[0].trim() },
|
||||
children: [],
|
||||
})
|
||||
|
||||
last = m.index + m[0].length
|
||||
m = variableRegex.exec(value)
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
if (last < value.length)
|
||||
parts.push({ type: 'text', value: value.slice(last) })
|
||||
|
||||
parent.children.splice(index, 1, ...parts)
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < node.children.length) {
|
||||
iterate(node.children[i], i, node)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < tree.children.length) {
|
||||
iterate(tree.children[i], i, tree)
|
||||
i++
|
||||
}
|
||||
variableRegex.lastIndex = 0
|
||||
return splitTextNode(value, variableRegex, match => ({
|
||||
tagName: 'variable',
|
||||
properties: { dataPath: match[0].trim() },
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function rehypeNotes() {
|
||||
return (tree: any) => {
|
||||
const iterate = (node: any, index: number, parent: any) => {
|
||||
const value = node.value
|
||||
return (tree: MarkdownNode) => {
|
||||
visitTextNodes(tree, (value, parent) => {
|
||||
noteRegex.lastIndex = 0
|
||||
if (!noteRegex.test(value))
|
||||
return null
|
||||
|
||||
noteRegex.lastIndex = 0
|
||||
if (node.type === 'text' && noteRegex.test(value)) {
|
||||
let m: RegExpExecArray | null
|
||||
let last = 0
|
||||
const parts: any[] = []
|
||||
noteRegex.lastIndex = 0
|
||||
m = noteRegex.exec(value)
|
||||
while (m !== null) {
|
||||
if (m.index > last)
|
||||
parts.push({ type: 'text', value: value.slice(last, m.index) })
|
||||
|
||||
const name = m[0].split('.').slice(-1)[0].replace('#}}', '')
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName: 'section',
|
||||
properties: { dataName: name },
|
||||
children: [],
|
||||
})
|
||||
|
||||
last = m.index + m[0].length
|
||||
m = noteRegex.exec(value)
|
||||
parent.tagName = 'div'
|
||||
return splitTextNode(value, noteRegex, (match) => {
|
||||
const name = match[0].split('.').slice(-1)[0].replace('#}}', '')
|
||||
return {
|
||||
tagName: 'section',
|
||||
properties: { dataName: name },
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
if (last < value.length)
|
||||
parts.push({ type: 'text', value: value.slice(last) })
|
||||
|
||||
parent.children.splice(index, 1, ...parts)
|
||||
parent.tagName = 'div' // h2 can not in p. In note content include the h2
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < node.children.length) {
|
||||
iterate(node.children[i], i, node)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < tree.children.length) {
|
||||
iterate(tree.children[i], i, tree)
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Variable: React.FC<{ path: string }> = ({ path }) => {
|
||||
return (
|
||||
<span className="text-text-accent">
|
||||
{
|
||||
path.replaceAll('.', '/')
|
||||
.replace('{{#', '{{')
|
||||
.replace('#}}', '}}')
|
||||
}
|
||||
{formatVariablePath(path)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -126,12 +135,7 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
|
||||
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
|
||||
const isVariable = defaultInput.type === 'variable'
|
||||
const path = `{{#${defaultInput.selector.join('.')}#}}`
|
||||
let newPath = path
|
||||
if (path) {
|
||||
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
|
||||
return `#${nodeName(nodeId)}${sep}`
|
||||
})
|
||||
}
|
||||
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
|
||||
return (
|
||||
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
|
||||
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}
|
||||
|
||||
Reference in New Issue
Block a user